微信小程序中使用canvas实现雷达图及标签对角显示(实现雷达图标签的标准做法)

一、效果图

二、完整代码及实现步骤

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.针对方案一和历史代码的修改过程记录

  1. 坐标系完全统一
    Canvas 绘制和标签定位全部使用 px 单位,彻底解决之前代码遗漏单位转换问题
    所有计算都基于radar-container容器的内部坐标系,与外部 padding、margin 无关
    雷达图半径计算与标签位置计算使用完全相同的公式,保证角度和距离完全匹配
  2. 逐标签独立精确控制
    每个标签都有 5 个独立参数:径向距离、水平偏移、垂直偏移、transform、文本对齐
    三个偏移参数配合可以实现任意位置的像素级精确调整(实现真正响应式标签,展示到对应角度旁边
    transform 值针对每个角度单独优化(之前是统一的transform值,所以非常不友好),让标签的边缘精确对准雷达角
  3. 保留动态计算的所有优势
    标签数量和位置完全由 JS 数据驱动,添加 / 删除场景只需要修改sceneList数组
    调整雷达图大小时,所有标签会自动跟随调整,只需要微调偏移量
    在所有设备、所有屏幕尺寸上的显示效果完全一致
  4. 代码结构清晰易维护
    所有配置集中在一个数组中,每行一个标签,一目了然...
    逻辑分离清晰,计算逻辑与配置逻辑完全分开,
相关推荐
棋宣3 小时前
uni-app编译到微信小程序中,父传子props首次传递数据不接收的bug
微信小程序·uni-app·bug
Lsx_1 天前
H5 嵌入微信 / 支付宝 / 抖音小程序 WebView:调用原生能力完整方案
前端·微信小程序·webview
计算机学姐2 天前
基于微信小程序的图书馆座位预约系统【uniapp+springboot+vue】
vue.js·spring boot·微信小程序·小程序·java-ee·uni-app·intellij-idea
WKK_2 天前
uniapp 微信小程序使用TextEncoder,arrayBufferToBase64
微信小程序·小程序·uni-app
舟遥遥娓飘飘2 天前
面向零基础初学者,从环境搭建到发布上线,手把手教你开发第一个微信小程序(第3章-认识项目结构)
微信小程序·小程序·notepad++
优睿远行2 天前
微信小程序自定义组件开发实战:从封装到发布的全流程指南
微信小程序·小程序·notepad++
Greg_Zhong2 天前
微信小程序中使用云函数调用豆包免费模型,部署云函数设置(触发器)执行每日自动生成书籍的文章赏析,完整过程
微信小程序·ai工程师·小程序中豆包模型调用·云函数配置触发器生成每日文章·微信云函数
eric*16882 天前
微信小程序全局安全水印组件实践:支持动态更新、全局生效、自定义样式
微信小程序·小程序
杰建云1673 天前
微信小程序自制全流程实测与避坑指南
微信小程序·小程序