当前位置: 首页 > news >正文

uniapp使用canvas绘制雷达图支持多维度

效果如图1.组件template !-- 雷达图容器使用view包裹canvas便于样式控制 -- view classradar-chart-container !-- canvas组件 - canvas-id唯一标识符用于创建canvas上下文 - idDOM标识与canvas-id保持一致 - style动态设置canvas宽高实现响应式布局 -- canvas :canvas-idcanvasId :idcanvasId classradar-canvas :widthcanvasWidth :heightcanvasHeight :style{ width: canvasWidth px, height: canvasHeight px } /canvas /view /template script // canvas-id计数器确保同一页面多个雷达图实例有唯一的canvas-id // 避免微信小程序中多个canvas冲突的问题 let canvasIdCounter 0 export default { name: RadarChart, props: { // 统一配置对象推荐使用该方式传参字段与下方各个props同名可覆盖默认值 // 例:options{ width: 300, height: 300, dimensions: [], values: [] } options: { type: Object, default: () ({}) }, // 雷达图宽度 width: { type: Number, default: 300 }, // 雷达图高度 height: { type: Number, default: 300 }, // 维度名称数组如[能力, 经验, 态度, 业绩] dimensions: { type: Array, required: true, default: () [] }, // 各维度对应的数值数组如[85, 70, 90, 75] values: { type: Array, required: true, default: () [] }, // 最大值用于计算数据比例默认100 maxValue: { type: Number, default: 100 }, // 雷达图网格层数默认5层 levels: { type: Number, default: 5 }, // 数据区域填充颜色 fillColor: { type: String, default: rgba(255, 0, 4, 0.7) }, // 数据区域边框颜色 strokeColor: { type: String, default: #FF0004 }, // 网格线颜色 gridColor: { type: String, default: #F2F3F5 }, // 网格线基础线宽单位px // 用于内层网格线与轴线的线宽控制 gridLineWidth: { type: Number, default: 1 }, // 外圈网格线线宽单位px // 设计稿外圈通常更醒目例如 2px gridOuterLineWidth: { type: Number, default: 2 }, // 维度标签文字颜色 textColor: { type: String, default: #646466 }, // 数据点颜色 pointColor: { type: String, default: #667eea }, // 数据点描边颜色 pointStrokeColor: { type: String, default: #ffffff }, // 数据点描边宽度 pointStrokeWidth: { type: Number, default: 2 }, // 数据点背景色 pointBgColor: { type: String, default: #ffffff }, // 数据点“白色间隙/留白圈”的宽度单位px // 效果红色圆点外面会有一圈白色的间隙外侧再接着红色的雷达区域贴近设计稿效果 // - 0不画白色间隙 // - 2常用值推荐从 2 开始微调 pointBgPadding: { type: Number, default: 1 }, pointRadius: { type: Number, default: 3 }, // 数据区域边框线宽 lineWidth: { type: Number, default: 2 }, // 维度标签字体大小 fontSize: { type: Number, default: 11 }, // 动画持续时间毫秒 animationDuration: { type: Number, default: 1000 }, // 中心文字内容 centerText: { type: String, default: }, // 中心文字颜色 centerTextColor: { type: String, default: #ffffff }, // 中心文字大小 centerTextSize: { type: Number, default: 16 }, // 是否显示维度分数 showDimensionScores: { type: Boolean, default: false }, // 维度分数颜色 scoreColor: { type: String, default: #A22C26 }, // 维度分数大小 scoreFontSize: { type: Number, default: 15 }, // 维度分数粗细 scoreFontWeight: { type: Number, default: 500 }, // 中心综合得分 centerScore: { type: [Number, String], default: }, // 中心综合得分颜色 centerScoreColor: { type: String, default: #ffffff }, // 中心综合得分大小 centerScoreSize: { type: Number, default: 24 }, // 雷达图内边距控制雷达图大小占比 padding: { type: Object, default: () ({ top: 0, right: 0, bottom: 0, left: 0 }) }, // 雷达图整体偏移单位px // 用途不改变 canvas 宽高的前提下微调雷达图在画布内的上下左右位置减少空白或做视觉居中 // 例{ x: 0, y: -20 } 表示整体向上移动 20px chartOffset: { type: Object, default: () ({ x: 0, y: 0 }) }, // 雷达图“外圈直径”单位px // 用于控制菱形四维/多边形多维整体大小常用于按设计稿固定图形尺寸 // 例150 表示最外圈宽高约为 150pxradius75 radarSize: { type: Number, default: 0 }, // 维度标签距离外圈的偏移单位px // 越大越远避免与网格重叠 labelOffset: { type: Number, default: 35 }, // 四维菱形专用文字与菱形角点的最小间距单位px // 说明 // - 该值会用于把文字块从菱形“角点”向外推开避免贴太近 // - 你提到的“2px 间距”就应该用这个参数来控制 // - 只在维度数为 4菱形时生效 diamondLabelOffset: { type: Number, default: 2 }, // 维度标签与外圈的额外间距单位px // 用途通用微调非菱形也可用默认不额外增加避免误解为“2px却很大” labelExtraGap: { type: Number, default: 0 }, // 两行标签分数/维度名之间的行距单位px // 传 0 则按字体大小自动计算 labelLineGap: { type: Number, default: 0 } }, data() { return { canvasId: , // canvas唯一标识符由计数器生成 canvasWidth: 0, // canvas实际宽度 canvasHeight: 0, // canvas实际高度 ctx: null, // canvas绘图上下文对象 centerX: 0, // 雷达图中心点X坐标 centerY: 0, // 雷达图中心点Y坐标 radius: 0, // 雷达图半径从中心到最外层的距离 animationProgress: 0, // 动画进度0-1之间控制绘制动画 animationTimer: null, // 动画定时器用于清除未完成的动画 currentDimensions: [], // 当前显示的维度数组内部状态 currentValues: [] // 当前显示的数值数组内部状态 } }, watch: { // 监听统一配置对象变化支持通过对象整体更新推荐 options: { handler() { // 配置发生变化时重新计算布局并重启动画 this.rebuildLayoutAndData(true) }, deep: true }, // 监听维度变化当父组件传入的dimensions改变时更新内部状态并重新播放动画 dimensions: { handler(newVal) { // 当不使用 options 或者父组件直接改 dimensions 时保持兼容 this.currentDimensions this.getConfig().dimensions this.startAnimation() }, deep: true // 深度监听确保数组内容变化也能触发 }, // 监听数值变化当父组件传入的values改变时更新内部状态并重新播放动画 values: { handler(newVal) { // 当不使用 options 或者父组件直接改 values 时保持兼容 this.currentValues this.getConfig().values this.startAnimation() }, deep: true // 深度监听确保数组内容变化也能触发 } }, mounted() { // 生成唯一的canvas-id避免同一页面多个实例冲突 this.canvasId radarChart_ (canvasIdCounter) // 初始化布局与数据支持 options 覆盖 this.rebuildLayoutAndData(false) // 等待DOM渲染完成后创建canvas上下文并开始动画 this.$nextTick(() { // 延迟200ms确保canvas元素已完全渲染 setTimeout(() { // 创建canvas绘图上下文 // 第二个参数this在微信小程序中是必需的用于指定组件实例 this.ctx uni.createCanvasContext(this.canvasId, this) // 启动绘制动画 this.startAnimation() }, 200) }) }, beforeDestroy() { // 组件销毁前清除动画定时器防止内存泄漏 if (this.animationTimer) { clearTimeout(this.animationTimer) } }, methods: { // 获取最终生效的配置 // - 如果传入 options则以 options 为准未传字段回退到对应 props 默认值 // - 如果未传 options则完全使用原有 props 方式保持向后兼容 getConfig() { const opts this.options || {} const pick (key, fallback) { return opts[key] ! undefined ? opts[key] : fallback } return { width: pick(width, this.width), height: pick(height, this.height), dimensions: pick(dimensions, this.dimensions), values: pick(values, this.values), maxValue: pick(maxValue, this.maxValue), levels: pick(levels, this.levels), padding: pick(padding, this.padding), fillColor: pick(fillColor, this.fillColor), strokeColor: pick(strokeColor, this.strokeColor), gridColor: pick(gridColor, this.gridColor), gridLineWidth: pick(gridLineWidth, this.gridLineWidth), gridOuterLineWidth: pick(gridOuterLineWidth, this.gridOuterLineWidth), textColor: pick(textColor, this.textColor), pointColor: pick(pointColor, this.pointColor), pointStrokeColor: pick(pointStrokeColor, this.pointStrokeColor), pointStrokeWidth: pick(pointStrokeWidth, this.pointStrokeWidth), pointBgColor: pick(pointBgColor, this.pointBgColor), pointBgPadding: pick(pointBgPadding, this.pointBgPadding), pointRadius: pick(pointRadius, this.pointRadius), lineWidth: pick(lineWidth, this.lineWidth), fontSize: pick(fontSize, this.fontSize), animationDuration: pick(animationDuration, this.animationDuration), centerText: pick(centerText, this.centerText), centerTextColor: pick(centerTextColor, this.centerTextColor), centerTextSize: pick(centerTextSize, this.centerTextSize), showDimensionScores: pick(showDimensionScores, this.showDimensionScores), scoreColor: pick(scoreColor, this.scoreColor), scoreFontSize: pick(scoreFontSize, this.scoreFontSize), scoreFontWeight: pick(scoreFontWeight, this.scoreFontWeight), centerScore: pick(centerScore, this.centerScore), centerScoreColor: pick(centerScoreColor, this.centerScoreColor), centerScoreSize: pick(centerScoreSize, this.centerScoreSize), chartOffset: pick(chartOffset, this.chartOffset), radarSize: pick(radarSize, this.radarSize), labelOffset: pick(labelOffset, this.labelOffset), diamondLabelOffset: pick(diamondLabelOffset, this.diamondLabelOffset), labelExtraGap: pick(labelExtraGap, this.labelExtraGap), labelLineGap: pick(labelLineGap, this.labelLineGap) } }, // 重新计算布局与内部数据 // - 当 options/尺寸/padding 发生变化时需要更新中心点与半径 // - 可选择是否立即重启动画用于watch场景 rebuildLayoutAndData(shouldAnimate) { const cfg this.getConfig() // 设置canvas宽高用于canvas style this.canvasWidth cfg.width this.canvasHeight cfg.height // 根据 padding 计算中心点坐标和半径 const padding cfg.padding || { top: 0, right: 0, bottom: 0, left: 0 } const availableWidth cfg.width - (padding.left || 0) - (padding.right || 0) const availableHeight cfg.height - (padding.top || 0) - (padding.bottom || 0) // 先算出“基础居中”的中心点 let centerX (padding.left || 0) availableWidth / 2 let centerY (padding.top || 0) availableHeight / 2 // 再叠加整体偏移用于微调位置不影响半径计算 const chartOffset cfg.chartOffset || { x: 0, y: 0 } centerX Number(chartOffset.x) || 0 centerY Number(chartOffset.y) || 0 this.centerX centerX this.centerY centerY // radius 计算逻辑 // - 默认用可用区域撑满兼容旧行为 // - 传了 radarSize以设计稿的“外圈直径”为准但不超过可用区域 const maxSizeInArea Math.min(availableWidth, availableHeight) const finalSize cfg.radarSize cfg.radarSize 0 ? Math.min(cfg.radarSize, maxSizeInArea) : maxSizeInArea this.radius finalSize / 2 // 初始化内部状态 this.currentDimensions Array.isArray(cfg.dimensions) ? cfg.dimensions : [] this.currentValues Array.isArray(cfg.values) ? cfg.values : [] // 已有ctx时可直接重启动画 if (shouldAnimate this.ctx) { this.startAnimation() } }, // 启动动画重置进度并开始新的动画 startAnimation() { // 如果已有动画在进行先清除 if (this.animationTimer) { clearTimeout(this.animationTimer) } // 重置动画进度为0 this.animationProgress 0 // 开始新的动画循环 this.animate() }, // 动画核心实现使用requestAnimationFrame的替代方案setTimeout // 实现从中心向外展开的动画效果 animate() { const startTime Date.now() // 记录动画开始时间 const duration this.getConfig().animationDuration // 动画总时长支持 options 覆盖 // 动画帧函数计算当前进度并重绘 const step () { // 计算已过去的时间 const elapsed Date.now() - startTime // 计算当前进度0到1之间Math.min确保不超过1 const t Math.min(elapsed / duration, 1) // 使用 ease-out 曲线让展开动画更自然 this.animationProgress 1 - Math.pow(1 - t, 3) // 根据当前进度绘制雷达图 this.draw() // 如果动画未完成进度小于1继续下一帧 // 16ms约等于60fps保证流畅的动画效果 if (this.animationProgress 1) { this.animationTimer setTimeout(step, 16) } } // 启动第一帧 step() }, // 绘制雷达图主函数协调各个绘制子函数 draw() { // 安全检查确保上下文和有效数据存在 if (!this.ctx || !this.currentDimensions || !this.currentValues || this.currentDimensions.length 0) { return } const cfg this.getConfig() const ctx this.ctx const count this.currentDimensions.length // 维度数量 // 清空canvas画布 ctx.clearRect(0, 0, cfg.width, cfg.height) // 按顺序绘制各个部分 this.drawGrid(ctx, count, cfg) // 绘制网格 this.drawAxis(ctx, count, cfg) // 绘制轴线 this.drawData(ctx, count, cfg) // 绘制数据区域 this.drawCenterText(ctx, cfg) // 绘制中心文字 this.drawLabels(ctx, count, cfg) // 绘制维度标签按图片分数在上维度在下 // 提交绘制到canvas微信小程序需要调用此方法才会显示 ctx.draw() }, // 绘制雷达图网格绘制多层同心多边形 drawGrid(ctx, count, cfg) { // 计算每个维度之间的角度间隔弧度 // Math.PI * 2 是一个完整的圆360度除以维度数量得到每个维度的角度 const angleStep (Math.PI * 2) / count // 从内到外绘制每一层网格 for (let level 1; level cfg.levels; level) { // 计算当前层的半径总半径除以层数再乘以当前层数 const r (this.radius / cfg.levels) * level ctx.beginPath() ctx.setStrokeStyle(cfg.gridColor) // 外圈网格线按设计稿通常更粗一些 ctx.setLineWidth(level cfg.levels ? cfg.gridOuterLineWidth : cfg.gridLineWidth) // 遍历每个维度计算多边形顶点坐标 for (let i 0; i count; i) { // 计算当前维度的角度 // 减去 Math.PI / 2 是为了让第一个维度指向正上方12点方向 const angle angleStep * i - Math.PI / 2 // 使用极坐标转直角坐标公式计算顶点位置 const x this.centerX r * Math.cos(angle) const y this.centerY r * Math.sin(angle) // 第一个点使用 moveTo后续点使用 lineTo 连接 if (i 0) { ctx.moveTo(x, y) } else { ctx.lineTo(x, y) } } // 闭合路径并描边 ctx.closePath() ctx.stroke() } }, // 绘制雷达图轴线从中心点向每个维度方向绘制射线 drawAxis(ctx, count, cfg) { const angleStep (Math.PI * 2) / count ctx.beginPath() ctx.setStrokeStyle(cfg.gridColor) // 轴线使用基础线宽避免画面过重 ctx.setLineWidth(cfg.gridLineWidth) // 为每个维度绘制一条从中心到边缘的轴线 for (let i 0; i count; i) { const angle angleStep * i - Math.PI / 2 // 计算轴线终点坐标 const x this.centerX this.radius * Math.cos(angle) const y this.centerY this.radius * Math.sin(angle) // 从中心点绘制到边缘 ctx.moveTo(this.centerX, this.centerY) ctx.lineTo(x, y) } ctx.stroke() }, // 绘制数据区域根据数据值绘制多边形区域并应用动画效果 drawData(ctx, count, cfg) { const angleStep (Math.PI * 2) / count ctx.beginPath() ctx.setFillStyle(cfg.fillColor) ctx.setStrokeStyle(cfg.strokeColor) ctx.setLineWidth(cfg.lineWidth) const points [] // 存储数据点坐标用于后续绘制数据点 // 遍历每个维度计算数据点位置 for (let i 0; i count; i) { const value this.currentValues[i] || 0 // 获取当前维度的值默认为0 const ratio Math.min(value / cfg.maxValue, 1) // 计算值占最大值的比例 // 核心动画逻辑 // 实际半径 理论半径 × 数据比例 × 动画进度 // 动画进度从0到1实现从中心向外展开的效果 const r this.radius * ratio * this.animationProgress const angle angleStep * i - Math.PI / 2 const x this.centerX r * Math.cos(angle) const y this.centerY r * Math.sin(angle) points.push({ x, y }) // 连接数据点形成多边形 if (i 0) { ctx.moveTo(x, y) } else { ctx.lineTo(x, y) } } // 闭合路径填充颜色并描边 ctx.closePath() ctx.fill() ctx.stroke() // 动画进度过半后才显示数据点 // 这样数据点会在多边形展开到一半时出现视觉效果更好 if (this.animationProgress 0.5) { points.forEach(point { // 绘制“白色间隙圈”关键用 stroke 来精确控制环的宽度 // 原理说明 // - 红点半径为 r // - 白色间隙宽度为 g // - 让圆弧半径为 r g/2线宽为 g则白色环刚好覆盖 [r, rg] const gapWidth Number(cfg.pointBgPadding) || 0 if (gapWidth 0) { ctx.beginPath() ctx.setStrokeStyle(cfg.pointBgColor) ctx.setLineWidth(gapWidth) ctx.arc(point.x, point.y, cfg.pointRadius gapWidth / 2, 0, Math.PI * 2) ctx.stroke() } // 绘制红色数据点放在白色环之后保证红点边缘更干净 ctx.beginPath() ctx.setFillStyle(cfg.pointColor) ctx.arc(point.x, point.y, cfg.pointRadius, 0, Math.PI * 2) ctx.fill() // 可选红点描边用于更强调点的边界 // 如果不需要描边把 pointStrokeWidth 设为 0 if (cfg.pointStrokeWidth 0) { ctx.beginPath() ctx.setStrokeStyle(cfg.pointStrokeColor) ctx.setLineWidth(cfg.pointStrokeWidth) ctx.arc(point.x, point.y, cfg.pointRadius, 0, Math.PI * 2) ctx.stroke() } }) } }, // 绘制中心文字在雷达图中心显示文字和综合得分 drawCenterText(ctx, cfg) { if (!cfg.centerText !cfg.centerScore) { return } // 让“综合得分”和中心分数整体垂直居中视觉更接近设计稿 const gap 6 const hasScore cfg.centerScore ! cfg.centerScore ! null cfg.centerScore ! undefined const hasText !!cfg.centerText let totalHeight 0 if (hasScore) totalHeight cfg.centerScoreSize if (hasText) totalHeight cfg.centerTextSize if (hasScore hasText) totalHeight gap const topY this.centerY - totalHeight / 2 let cursorY topY ctx.setTextAlign(center) ctx.setTextBaseline(middle) if (hasScore) { const y cursorY cfg.centerScoreSize / 2 ctx.setFillStyle(cfg.centerScoreColor) ctx.setFontSize(cfg.centerScoreSize) ctx.fillText(String(cfg.centerScore), this.centerX, y) cursorY cfg.centerScoreSize (hasText ? gap : 0) } if (hasText) { const y cursorY cfg.centerTextSize / 2 ctx.setFillStyle(cfg.centerTextColor) ctx.setFontSize(cfg.centerTextSize) ctx.fillText(cfg.centerText, this.centerX, y) } }, // 绘制维度标签按图片效果分数在上、维度名在下两行 drawLabels(ctx, count, cfg) { const angleStep (Math.PI * 2) / count // 两行之间的间距 // - 如果传了 labelLineGap0优先使用 // - 否则按字体大小自动计算确保上下两行整体“居中”且不挤 const lineGap cfg.labelLineGap cfg.labelLineGap 0 ? cfg.labelLineGap : Math.max(8, Math.round((cfg.fontSize cfg.scoreFontSize) / 5)) // 统一的文本基线设为 middle方便做上下两行排版 ctx.setTextBaseline(middle) for (let i 0; i count; i) { const angle angleStep * i - Math.PI / 2 // 四维菱形按设计稿处理 // 四维菱形按设计稿处理 // - 左/右分数与维度名要“同一列居中”不能 left/right 对齐否则会出现你说的“不垂直居中” // - 上/下按“离角点最小间距”来推开文字并且上/下两端的“靠近菱形的那一行”不同 // - 上方靠近菱形的是维度名 // - 下方靠近菱形的是分数 const useDiamondLayout count 4 // 默认坐标非四维走这里 let x 0 let y 0 // 预先算出本维度的文字内容后续用于测宽 const hasScores !!cfg.showDimensionScores const rawValue this.currentValues[i] const valueText hasScores ? this.formatScore(rawValue) : const dimensionText this.currentDimensions[i] || if (!useDiamondLayout) { // 非四维使用基础策略放到外圈外一圈 const labelRadius this.radius (Number(cfg.labelOffset) || 0) x this.centerX labelRadius * Math.cos(angle) y this.centerY labelRadius * Math.sin(angle) let textAlign center if (Math.abs(Math.cos(angle)) 0.1) { textAlign Math.cos(angle) 0 ? left : right } ctx.setTextAlign(textAlign) } else { // 四维以外圈角点为基准定位更可控 const cornerX this.centerX this.radius * Math.cos(angle) const cornerY this.centerY this.radius * Math.sin(angle) // 角点到文字的最小间距px // 这里的 2 就是真正的 2px不会被 labelOffset 放大 const minGap (Number(cfg.diamondLabelOffset) || 0) (Number(cfg.labelExtraGap) || 0) // 计算文本块最大宽度用于左右两侧推开 const scoreWidth hasScores ? this.measureTextWidth(ctx, valueText, cfg.scoreFontSize) : 0 const labelWidth this.measureTextWidth(ctx, dimensionText, cfg.fontSize) const blockWidth Math.max(scoreWidth, labelWidth) const absCos Math.abs(Math.cos(angle)) const absSin Math.abs(Math.sin(angle)) // 四维统一用居中对齐保证分数与维度名在同一列 ctx.setTextAlign(center) if (absCos absSin) { // 左/右整体块中心 y 与角点 y 对齐垂直居中 const dir Math.cos(angle) 0 ? 1 : -1 x cornerX dir * (minGap blockWidth / 2) y cornerY } else { // 上/下x 与角点对齐y 后续根据“靠近菱形的那一行”单独计算 x cornerX y cornerY } } if (cfg.showDimensionScores) { const rawValue this.currentValues[i] let scoreY 0 let labelY 0 if (useDiamondLayout) { // 角点坐标用于上下方向计算 const cornerX this.centerX this.radius * Math.cos(angle) const cornerY this.centerY this.radius * Math.sin(angle) const minGap (Number(cfg.diamondLabelOffset) || 0) (Number(cfg.labelExtraGap) || 0) const absCos Math.abs(Math.cos(angle)) const absSin Math.abs(Math.sin(angle)) if (absCos absSin) { // 左/右两行作为一个块整体以 y 为中心垂直居中 const groupHeight cfg.scoreFontSize lineGap cfg.fontSize const groupTopY y - groupHeight / 2 scoreY groupTopY cfg.scoreFontSize / 2 labelY groupTopY cfg.scoreFontSize lineGap cfg.fontSize / 2 } else { // 上/下按“最小间距”来推开靠近菱形的那一行 const isTop Math.sin(angle) 0 if (isTop) { // 上方靠近菱形的是维度名label labelY cornerY - (minGap cfg.fontSize / 2) scoreY labelY - (cfg.fontSize / 2 lineGap cfg.scoreFontSize / 2) } else { // 下方靠近菱形的是分数score scoreY cornerY (minGap cfg.scoreFontSize / 2) labelY scoreY (cfg.scoreFontSize / 2 lineGap cfg.fontSize / 2) } x cornerX } } else { // 非四维两行作为一个块整体以 y 为中心垂直居中 const groupHeight cfg.scoreFontSize lineGap cfg.fontSize const groupTopY y - groupHeight / 2 scoreY groupTopY cfg.scoreFontSize / 2 labelY groupTopY cfg.scoreFontSize lineGap cfg.fontSize / 2 } // 第一行分数红色 ctx.setFillStyle(cfg.scoreColor) ctx.setFontSize(cfg.scoreFontSize) ctx.fillText(valueText, x, scoreY) // 第二行维度名灰色 ctx.setFillStyle(cfg.textColor) ctx.setFontSize(cfg.fontSize) ctx.fillText(dimensionText, x, labelY) } else { ctx.setFillStyle(cfg.textColor) if (useDiamondLayout) { const cornerX this.centerX this.radius * Math.cos(angle) const cornerY this.centerY this.radius * Math.sin(angle) const minGap (Number(cfg.diamondLabelOffset) || 0) (Number(cfg.labelExtraGap) || 0) const absCos Math.abs(Math.cos(angle)) const absSin Math.abs(Math.sin(angle)) ctx.setTextAlign(center) if (absCos absSin) { const scoreWidth 0 const labelWidth this.measureTextWidth(ctx, dimensionText, cfg.fontSize) const blockWidth Math.max(scoreWidth, labelWidth) const dir Math.cos(angle) 0 ? 1 : -1 x cornerX dir * (minGap blockWidth / 2) y cornerY } else { const dir Math.sin(angle) 0 ? 1 : -1 x cornerX y cornerY dir * (minGap cfg.fontSize / 2) } } ctx.setFontSize(cfg.fontSize) ctx.fillText(this.currentDimensions[i] || , x, y) } } }, // 计算文本宽度单位px // 注意不同端 canvas 的 measureText 能力不一致这里做降级处理保证布局可用 measureTextWidth(ctx, text, fontSize) { const safeText text undefined || text null ? : String(text) const safeFontSize Number(fontSize) || 12 // 优先使用原生 measureText更准确 try { if (ctx typeof ctx.setFontSize function) { ctx.setFontSize(safeFontSize) } if (ctx typeof ctx.measureText function) { const res ctx.measureText(safeText) if (res typeof res.width number) { return res.width } } } catch (e) { // 忽略异常走降级估算 } // 降级估算中文按 1em英文/数字按 0.55em let width 0 for (let i 0; i safeText.length; i) { const code safeText.charCodeAt(i) const isAscii code 0x20 code 0x7e width safeFontSize * (isAscii ? 0.55 : 1) } return width }, // 格式化维度分数显示 // - 数字统一保留1位小数贴近设计稿4.4 / 4.3 // - 非数字则按字符串原样显示避免异常 formatScore(value) { const num Number(value) if (!isFinite(num)) { return value undefined || value null ? : String(value) } return num.toFixed(1) }, handleTouchStart(e) { this.$emit(touchstart, e) }, updateData(newValues) { this.currentValues newValues this.startAnimation() }, exportImage() { return new Promise((resolve, reject) { uni.canvasToTempFilePath({ canvasId: this.canvasId, success: (res) { resolve(res.tempFilePath) }, fail: (err) { reject(err) } }, this) }) } } } /script style scoped .radar-chart-container { display: flex; justify-content: center; align-items: center; width: 100%; } .radar-canvas { background-color: #ffffff; } /style2.页面使用template radar-chart :optionsradar4Options/radar-chart /template script import RadarChart from /components/radar-chart/radar-chart.vue export default { components: { RadarChart }, data() { return { // 屏幕可用宽度px用于让雷达图按手机宽度自适应避免整体偏移 screenWidthPx: 375, // 页面/容器的左右内边距rpx用于计算雷达图实际可用宽度 // 这里要与 style 里的 padding 保持一致防止计算偏差导致居中不准 pagePaddingRpx: 20, sectionPaddingRpx: 30, // 四维雷达图顺序对应上/右/下/左方便做出图片里的菱形布局 dimensions4: [专业能力, 消息回复时效, 着装及谈吐, 服务意识], values4: [4.4, 4.3, 3.3, 4.5,], dimensions5: [技术, 沟通, 管理, 创新, 执行], values5: [80, 65, 75, 90, 70], dimensions6: [技术, 沟通, 管理, 创新, 执行, 协作], values6: [85, 70, 60, 80, 75, 90], dynamicDimensions: [维度1, 维度2, 维度3, 维度4], dynamicValues: [70, 80, 60, 90] } }, onLoad() { // 获取手机屏幕宽度px用于自适应计算雷达图宽高 const info uni.getSystemInfoSync() this.screenWidthPx info info.windowWidth ? info.windowWidth : 375 }, computed: { // 统一对象传参推荐字段名与 radar-chart 组件的 props 一致 radar4Options() { // 计算雷达图容器可用宽度px // 说明 // - rpx 在不同机型会换算成不同 px为避免整体偏移这里用 upx2px 做精确换算 // - 结构上page-container 有左右 paddingchart-section 也有左右 padding const pagePaddingPx uni.upx2px(this.pagePaddingRpx) const sectionPaddingPx uni.upx2px(this.sectionPaddingRpx) const chartWidthPx Math.max(0, this.screenWidthPx - (pagePaddingPx sectionPaddingPx) * 2) return { dimensions: this.dimensions4, values: this.values4, // 容器按可用宽度自适应保持正方形居中不偏移 width: chartWidthPx, height: 250, // 让“菱形外圈”按设计稿固定为 150px不影响canvas整体大小 radarSize: 150, maxValue: 5, levels: 5, // padding 建议为 0通过 labelOffset 控制标签与外圈距离避免固定 padding 在不同手机上造成偏移 padding: { top: 0, right: 0, bottom: 0, left: 0 }, // 画布内位置微调单位px // - y 负数整体上移减少上方空白 // - y 正数整体下移 // - x 负数整体左移 // - x 正数整体右移 chartOffset: { x: -10, y: 0 }, fillColor: rgba(255, 0, 4, 0.7), strokeColor: #FF0004, gridColor: #F2F3F5, // 外圈线宽按设计稿 2px gridLineWidth: 1, gridOuterLineWidth: 2, // 四维菱形角点到文字的最小间距单位 px // 这里的 2 就是真正意义上的 2px diamondLabelOffset: 2, // 点大小相关按图2调小 pointColor: #FF0004, pointBgColor: #ffffff, pointStrokeColor: #FF0004, pointRadius: 3, // 白色间隙圈宽度单位 px越大白色圈越粗 pointBgPadding: 2, // 红点描边如果不需要设为 0更贴近设计稿红点 白色间隙圈 pointStrokeWidth: 0, showDimensionScores: true, scoreColor: #B22222, // 分数字体大小按需调大/调小例如 24、28 scoreFontSize: 20, centerText: 综合得分, // 综合得分文字颜色按需求改为白色 centerTextColor: #ffffff, // “综合得分”文字大小按需调整 centerTextSize: 14, centerScore: 4.2, centerScoreColor: #fff, // 中心分数字体大小按需调整 centerScoreSize: 28 } } }, methods: { onSliderChange(e, index) { this.dynamicValues[index] e.detail.value } } } /script
http://www.zskr.cn/news/1373343.html

相关文章:

  • PyTorch代码(5)
  • Claude Code完整安装与配置指南
  • 【助睿实验指导】学生用户画像 - 考勤画像可视化分析
  • 【AI工具】wsl2 + ubuntu22.04安装部署sub2api详细教程
  • 山大软院创新项目实训个人博客——诈骗克星(五)
  • 2026职场差旅装备指南:商务出差拉杆箱选型避坑与实测推荐
  • b4a用VB语言开发安卓APP-图片缩放库ZoomImageView讲解-双指缩放 + 单指拖动核心源码
  • 项目经理的终极困境:资源永远不够,高手靠取舍赢结果
  • AArch64异常处理机制详解与ARMv8架构实践
  • MyBatis:复杂结果集映射与分步查询
  • CentOS 7服务器管理员的福音:手把手配置fbterm终端,实现中英文无缝切换
  • 简历写“熟练Office”算造假?HR公认的真实标准,别再踩坑
  • 2026年蒸发式冷却塔怎么选:闭式冷却塔、不锈钢冷却塔、冷却塔填料、凉水塔、圆形冷却塔、横流式冷却塔、玻璃钢冷却塔选择指南 - 优质品牌商家
  • 2026双头超声波机厂家怎么选:非标订做超声波清洗机/伺服超声波/包布热压机/单头高周波机/双头高周波机/同步熔断机/选择指南 - 优质品牌商家
  • Ubuntu 22.04蓝牙开关秒关?别慌,先看dmesg日志里的这个Intel固件报错
  • 项目上传到gitee的两种方式,ssh和https
  • 面试题——全局邮件的设计
  • 从‘光程差为零’出发:手把手推导超透镜的相位公式(附Python验证代码)
  • 如何用pyTMD实现高精度潮汐预测:从入门到实战的完整指南
  • 用“挑西瓜”讲透《机器学习》第六章-支持向量机
  • Java内部类全解析:四种类型核心原理与实战理解
  • 腾讯云TRTC、声网、即构三款实时音视频SDK怎么选?2026实测对比
  • 2026高压发泡机技术解析:弹性体发泡机/方向盘高压泡机/水箱PU发泡机/热水器发泡机/热水器环戊烷发泡机/环戊烷发泡机/选择指南 - 优质品牌商家
  • 新电脑到手第一件事:关闭Windows 11/10的自动BitLocker加密(附详细路径图)
  • 保姆级教程:手把手教你用NVIDIA Surround搞定Prepar3D多屏显示(Win10/Win11通用)
  • 别再死记硬背!用Python代码和D-Separation定理,5分钟搞懂贝叶斯网络的4种基本结构
  • 位置编码——给序列安上坐标
  • 接入内网工具删除
  • 从Stata/R代码实操出发:手把手教你用双重差分法(DID)评估一个‘政策’的真实效果
  • 不只是编译:在龙芯3A4000的银河麒麟V10上,给FileZilla解决gnutls和wxWidgets依赖的完整思路