一、效果图
二、完整代码及实现步骤
javascript
Page({
data: {
currentTab: 0,
tabs: ['SADL满意度', '场景适应', 'CHHIE-S'],
// 12角雷达图(顺时针标签)
sceneList: [
{ name: '家庭聚餐', category: '社交与餐饮', before: 4, after: 2, change: -2 },
{ name: '嘈杂餐厅', category: '社交与餐饮', before: 5, after: 3, change: -2 },
{ name: '广场舞', category: '社交与餐饮', before: 4, after: 2, change: -2 },
{ name: '地铁公交', category: '公共交通与出行', before: 4, after: 3, change: -1 },
{ name: '高铁机场', category: '公共交通与出行', before: 5, after: 4, change: -1 },
{ name: '过马路', category: '公共交通与出行', before: 4, after: 2, change: -2 },
{ name: '办公会议', category: '职场与教育', before: 3, after: 2, change: -1 },
{ name: '讲座培训', category: '职场与教育', before: 4, after: 2, change: -2 },
{ name: '视频通话', category: '数字化与服务', before: 4, after: 2, change: -2 },
{ name: '政务柜台', category: '数字化与服务', before: 5, after: 1, change: -4 },
{ name: '家庭电视', category: '家庭与个人', before: 4, after: 2, change: -2 },
{ name: '菜市场', category: '家庭与个人', before: 4, after: 2, change: -2 }
],
// 雷达图数据
sceneRadarData: {
before: [4, 5, 4, 4, 5, 4, 3, 4, 4, 5, 4, 4],
after: [2, 3, 2, 3, 4, 2, 2, 2, 2, 1, 2, 2]
},
// 雷达图标签位置
radarLabels: [],
},
onLoad() {
this.initRadar();
},
//每一个标签,支持标签上下左右微调、角度的微调
initRadar() {
const canvasSize = 260; // px
const centerX = canvasSize / 2;
const centerY = canvasSize / 2;
// 与Canvas绘制逻辑完全一致的雷达半径
const radarRadius = canvasSize * 0.75 / 2; // = 97.5px
const sides = 12;
const angleStep = (2 * Math.PI) / sides; // 30度 = π/6弧度
const labels = [];
// 格式:{ r:径向距离, x:水平偏移(+右-左), y:垂直偏移(+下-上), t:变换, a:对齐 } // 索引: 标签名(角度)
const labelConfigs = [
{ r: 6, x: 0, y: 0, t: 'translate(-50%, -100%)', a: 'center' }, // 0: 家庭聚餐(顶部)
{ r: 6, x: 0, y: 0, t: 'translate(0%, -100%)', a: 'left' }, // 1: 嘈杂餐厅(右上30°)
{ r: 6, x: 0, y: -5, t: 'translate(0%, -50%)', a: 'left' }, // 2: 广场舞(右60°)
{ r: 6, x: -3, y: -20, t: 'translate(0%, 0%)', a: 'left' }, // 3: 地铁公交(右下90°)
{ r: 6, x: 0, y: -22, t: 'translate(0%, 0%)', a: 'left' }, // 4: 高铁机场(最右下120°)
{ r: 8, x: -5, y: -20, t: 'translate(0%, 0%)', a: 'right' }, // 5: 过马路(底部右150°)
{ r: -12, x: 0, y: 0, t: 'translate(-50%, 0%)', a: 'center' }, // 6: 办公会议(底部)
{ r: 8, x: 0, y: -20, t: 'translate(-100%, 0%)', a: 'right' }, // 7: 讲座培训(底部左210°)
{ r: 8, x: 6, y: -2, t: 'translate(-100%, -50%)', a: 'right' }, // 8: 视频通话(左下240°)
{ r: 6, x: 0, y: -6, t: 'translate(-100%, -50%)', a: 'right' }, // 9: 政务柜台(左270°)
{ r: 8, x: 0, y: 6, t: 'translate(-100%, -100%)', a: 'right' }, // 10: 家庭电视(左上300°)
{ r: 8, x: -15, y: 0, t: 'translate(0%, -100%)', a: 'center' } // 11: 菜市场(顶部左330°)
];
for (let i = 0; i < sides; i++) {
const angle = i * angleStep - Math.PI / 2;
const c = labelConfigs[i];
// 基础位置计算 + 独立微调
let x = centerX + (radarRadius + c.r) * Math.cos(angle) + c.x;
let y = centerY + (radarRadius + c.r) * Math.sin(angle) + c.y;
labels.push({
name: this.data.sceneList[i].name,
left: x,
top: y,
align: c.a,
transform: c.t
});
}
this.setData({ radarLabels: labels }, () => {
this.drawSceneRadar(canvasSize, canvasSize);
});
},
// 绘制雷达图(保持不变,因为已经使用px单位)
drawSceneRadar(canvasWidth, canvasHeight) {
const ctx = wx.createCanvasContext('sceneRadarCanvas', this);
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const radius = Math.min(centerX, centerY) * 0.75; // 雷达图占Canvas的75%
const sides = 12;
const angleStep = (2 * Math.PI) / sides;
// 清空画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 绘制背景网格(6层)
ctx.setStrokeStyle('rgba(148, 163, 184, 0.25)');
ctx.setLineWidth(1);
for (let i = 1; i <= 6; i++) {
ctx.beginPath();
const r = (radius / 6) * i;
for (let j = 0; j < sides; j++) {
const angle = j * angleStep - Math.PI / 2;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
j === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
// 绘制轴线
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = i * angleStep - Math.PI / 2;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
ctx.moveTo(centerX, centerY);
ctx.lineTo(x, y);
}
ctx.stroke();
// 绘制前测数据
this.drawRadarData(ctx, centerX, centerY, radius, sides, this.data.sceneRadarData.before, '#94a3b8', 'rgba(148, 163, 184, 0.15)');
// 绘制后测数据
this.drawRadarData(ctx, centerX, centerY, radius, sides, this.data.sceneRadarData.after, '#0ea5e9', 'rgba(14, 165, 233, 0.2)');
ctx.draw();
},
// 绘制雷达图数据(保持不变)
drawRadarData(ctx, centerX, centerY, radius, sides, data, strokeColor, fillColor) {
const angleStep = (2 * Math.PI) / sides;
const maxValue = 5; // 最大值为5
// 绘制填充区域
ctx.beginPath();
ctx.setFillStyle(fillColor);
for (let i = 0; i < sides; i++) {
const value = data[i] || 0;
const r = (radius * value) / maxValue;
const angle = i * angleStep - Math.PI / 2;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.fill();
// 绘制边框线
ctx.beginPath();
ctx.setStrokeStyle(strokeColor);
ctx.setLineWidth(2);
for (let i = 0; i < sides; i++) {
const value = data[i] || 0;
const r = (radius * value) / maxValue;
const angle = i * angleStep - Math.PI / 2;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.closePath();
ctx.stroke();
// 绘制数据点
for (let i = 0; i < sides; i++) {
const value = data[i] || 0;
const r = (radius * value) / maxValue;
const angle = i * angleStep - Math.PI / 2;
const x = centerX + r * Math.cos(angle);
const y = centerY + r * Math.sin(angle);
ctx.beginPath();
ctx.arc(x, y, 3, 0, 2 * Math.PI);
ctx.setFillStyle(strokeColor);
ctx.fill();
}
},
});
html
<!-- 场景能力雷达图 -->
<view class="card radar-card">
<text class="card-title">场景能力雷达图</text>
<view class="radar-wrapper">
<!-- 关键:使用相对定位的容器包裹Canvas和标签 -->
<view class="radar-container">
<canvas canvas-id="sceneRadarCanvas" class="radar-canvas" style="width: 260px; height: 260px;"></canvas>
<!-- 动态渲染标签,使用计算好的位置 -->
<view class="radar-labels">
<text class="radar-label" wx:for="{{radarLabels}}" wx:key="index"
style="left: {{item.left}}px; top: {{item.top}}px; text-align: {{item.align}}; transform: {{item.transform}};">
{{item.name}}
</text>
</view>
</view>
<view class="radar-legend">
<view class="legend-item">
<view class="legend-line gray"></view>
<text class="legend-text">Day 1 前测</text>
</view>
<view class="legend-item">
<view class="legend-line blue"></view>
<text class="legend-text">Day 30 后测</text>
</view>
</view>
</view>
</view>
css
/* 卡片基础样式 - 保留你原来的样式 */
.card {
background-color: #ffffff;
border-radius: 30rpx;
padding: 30rpx;
margin: 0 40rpx 25rpx 40rpx;
box-shadow: 0 2rpx 26rpx 0 rgba(140, 163, 197, 0.15);
}
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #1f2937;
margin-bottom: 20rpx;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 新增:雷达图容器 - 关键!使用相对定位作为基准 */
.radar-container {
position: relative;
width: 260px;
height: 260px;
margin: 0 auto; /* 水平居中 */
}
.radar-canvas {
display: block;
}
/* 标签容器 - 绝对定位覆盖在Canvas上 */
.radar-labels {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 让标签不阻挡Canvas事件 */
}
/* 标签样式 - 使用绝对定位 */
.radar-label {
position: absolute;
font-size: 24rpx;
color: #64748b;
white-space: nowrap;
/* 关键:让标签中心对准计算点 */
/* transform: translate(-50%, -50%); */
}
.radar-legend {
display: flex;
justify-content: center;
gap: 40rpx;
margin-top: 30rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.legend-line {
width: 40rpx;
height: 4rpx;
border-radius: 2rpx;
}
.legend-line.gray {
background-color: #94a3b8;
}
.legend-line.blue {
background-color: #0ea5e9;
}
.legend-text {
font-size: 24rpx;
color: #64748b;
}
三、问题及总结(标签响应式展示到角的位置)
方案一: 使用静态硬编码+transform: translate(-50%, -50%)让标签的中心点统一对准雷达角的方案
问题:12个标签都要调整,而且响应式效果非法不友好(统一的 transform 值导致不同方向的标签视觉距离差异巨大)
方案二 · 主流做法: 图形 Canvas 化 + 标签 DOM 化的分离式实现
1. 核心:三参数微调控制标签
径向距离+水平偏移+垂直偏移三参数进行控制
既可以批量调整所有标签的整体距离,又可以对个别标签进行像素级微调
完美解决了ui设计稿的 "像素级还原" 的苛刻要求,,,
2.针对方案一和历史代码的修改过程记录
- 坐标系完全统一
Canvas 绘制和标签定位全部使用 px 单位,彻底解决之前代码遗漏单位转换问题
所有计算都基于radar-container容器的内部坐标系,与外部 padding、margin 无关
雷达图半径计算与标签位置计算使用完全相同的公式,保证角度和距离完全匹配 - 逐标签独立精确控制
每个标签都有 5 个独立参数:径向距离、水平偏移、垂直偏移、transform、文本对齐
三个偏移参数配合可以实现任意位置的像素级精确调整(实现真正响应式标签,展示到对应角度旁边)
transform 值针对每个角度单独优化(之前是统一的transform值,所以非常不友好),让标签的边缘精确对准雷达角 - 保留动态计算的所有优势
标签数量和位置完全由 JS 数据驱动,添加 / 删除场景只需要修改sceneList数组
调整雷达图大小时,所有标签会自动跟随调整,只需要微调偏移量
在所有设备、所有屏幕尺寸上的显示效果完全一致 - 代码结构清晰易维护
所有配置集中在一个数组中,每行一个标签,一目了然...
逻辑分离清晰,计算逻辑与配置逻辑完全分开,

