| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920 |
- <template>
- <view class="radar-chart-wrap">
- <view
- class="radar-container"
- :style="containerStyle"
- >
- <canvas
- :id="canvasId"
- type="2d"
- class="radar-canvas"
- :style="canvasStyle"
- ></canvas>
- <!-- 透明点击感应层 -->
- <view class="radar-tap-layer" @click="onCanvasTap"></view>
- </view>
- <!-- 图例:实际值 / 人群位置 / 家庭平均 -->
- <view class="radar-legend" v-if="hasMultipleSeries">
- <view class="legend-item">
- <view class="legend-dot" :style="{ background: fillColor }"></view>
- <text class="legend-text">实际值</text>
- </view>
- <view class="legend-item" v-if="populationScores.length > 0">
- <view class="legend-line" :style="{ borderColor: populationColor }"></view>
- <text class="legend-text">人群位置</text>
- </view>
- <view class="legend-item" v-if="familyScores.length > 0">
- <view class="legend-line" :style="{ borderColor: avgFillColor, borderStyle: 'dashed' }"></view>
- <text class="legend-text">家庭平均</text>
- </view>
- </view>
- </view>
- </template>
- <script>
- /**
- * RadarChart - 六维雷达图 Canvas 组件
- *
- * 基于 HTML5 Canvas 2D 渲染,支持 DPR 适配、入场动画、点击事件。
- * 用于「心智」页面展示六维认知能力评分。
- *
- * 用法:
- * <RadarChart
- * :dimensions="dimList"
- * :scores="[85, 72, 68, 90, 55, 78]"
- * childName="小明"
- * @dimensionClick="onDimClick"
- * />
- */
- export default {
- props: {
- /** 六维标签 [{label, key}, ...],顺序:感知/专注/记忆/逻辑/空间/加工速度 */
- dimensions: {
- type: Array,
- default: function() {
- return []
- }
- },
- /** 六维分值 0-100,与 dimensions 顺序一一对应 */
- scores: {
- type: Array,
- default: function() {
- return []
- }
- },
- /** 填充颜色(可选,默认暖橙) */
- fillColor: {
- type: String,
- default: '#F97316'
- },
- /** 网格颜色(可选,默认浅灰) */
- gridColor: {
- type: String,
- default: '#E2E8F0'
- },
- /** 标签颜色(可选,默认深灰) */
- labelColor: {
- type: String,
- default: '#1E293B'
- },
- /** 图表标题(显示在中心) */
- childName: {
- type: String,
- default: ''
- },
- /** 图表宽度(rpx) */
- width: {
- type: Number,
- default: 600
- },
- /** 图表高度(rpx) */
- height: {
- type: Number,
- default: 600
- },
- /** 平均值分数 0-100,与 dimensions 顺序一一对应(可选,显示为灰色虚线)—— 语义:家庭平均分 */
- avgScores: {
- type: Array,
- default: function() {
- return []
- }
- },
- /** 平均值填充颜色(可选,默认灰色) */
- avgFillColor: {
- type: String,
- default: '#CBD5E1'
- },
- /** 平均值网格颜色(可选,默认浅灰) */
- avgGridColor: {
- type: String,
- default: '#E2E8F0'
- },
- /** 平均值标签颜色(可选,默认浅灰) */
- avgLabelColor: {
- type: String,
- default: '#94A3B8'
- },
- /** 人群位置分数 0-100(同龄同性常模 P50/P75 等),显示为蓝色虚线多边形 */
- populationScores: {
- type: Array,
- default: function() {
- return []
- }
- },
- /** 人群位置颜色(可选,默认蓝色) */
- populationColor: {
- type: String,
- default: '#3B82F6'
- },
- /** 人群位置填充颜色(可选,默认蓝色半透明) */
- populationFillColor: {
- type: String,
- default: '#DBEAFE'
- },
- /** 人群位置网格颜色(可选) */
- populationGridColor: {
- type: String,
- default: '#BFDBFE'
- }
- },
- data: function() {
- return {
- canvasReady: false,
- canvasWidth: 0,
- canvasHeight: 0,
- ctx: null,
- dpr: 1,
- _canvasNode: null,
- _canvasLeft: 0,
- _canvasTop: 0,
- /** 动画插值中的分数 */
- _animScores: [],
- _animId: null,
- /** 当前实例唯一的 canvas id(同页多实例时避免 select('#radarCanvas') 命中错误节点) */
- canvasId: 'radarCanvas_' + (this._uid || 0),
- /** 组件已销毁标志:mounted 的延迟初始化与重试链需据此停止,避免销毁后继续查询节点刷错误日志 */
- _destroyed: false,
- /** mounted 延迟初始化定时器引用(beforeDestroy 时清理) */
- _initTimer: null
- }
- },
- computed: {
- /** 维度数量(灵活支持 N 维雷达图) */
- dimCount: function() {
- return this.dimensions ? this.dimensions.length : 0
- },
- /** 容器内联样式(字符串形式,微信小程序 style 绑定只可靠支持字符串) */
- containerStyle: function() {
- return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx'
- },
- /** canvas 内联样式:显式撑开尺寸,避免 100%/100% 在 flex 容器中塌陷为 0 */
- canvasStyle: function() {
- return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx'
- },
- /** N 边形的 N 个顶点的标准角度(弧度),从顶部顺时针 */
- vertexAngles: function() {
- var n = this.dimCount
- if (n < 3) return []
- var angles = []
- for (var i = 0; i < n; i++) {
- angles.push(-Math.PI / 2 + (i * 2 * Math.PI) / n)
- }
- return angles
- },
- /** 是否有多个数据系列(用于图例显示) */
- hasMultipleSeries: function() {
- return (this.populationScores && this.populationScores.length >= this.dimCount) ||
- (this.avgScores && this.avgScores.length >= this.dimCount)
- },
- /** 统一的家庭平均分(兼容旧 prop) */
- familyScores: function() {
- return this.avgScores || []
- }
- },
- watch: {
- scores: {
- handler: function(val) {
- if (!Array.isArray(val)) return
- if (this.canvasReady) {
- this._startAnimation()
- }
- },
- deep: true
- },
- avgScores: {
- handler: function() {
- if (this.canvasReady) {
- this._doRender()
- }
- },
- deep: true
- },
- populationScores: {
- handler: function() {
- if (this.canvasReady) {
- this._doRender()
- }
- },
- deep: true
- }
- },
- mounted: function() {
- var self = this
- // 等 DOM 布局完成后初始化 Canvas(带重试,应对微信小程序首次渲染 Canvas 节点延迟)
- var retryCount = 0
- var maxRetry = 5
- function tryInit() {
- if (self._destroyed) return
- self.initCanvas(function(success) {
- if (self._destroyed) return
- if (!success && retryCount < maxRetry) {
- retryCount++
- self._initTimer = setTimeout(tryInit, 60)
- } else if (!success) {
- console.log('[RadarChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
- }
- })
- }
- self._initTimer = setTimeout(tryInit, 100)
- },
- beforeDestroy: function() {
- this._destroyed = true
- if (this._initTimer) {
- clearTimeout(this._initTimer)
- this._initTimer = null
- }
- if (this._animId) {
- this._cancelAnim(this._animId)
- this._animId = null
- }
- },
- methods: {
- /* ===========================
- * 工具:分数归一化(null/undefined/NaN 一律按 0 处理)
- * 防止后端返回 null score 时 Canvas 计算 NaN 导致 moveTo/lineTo 静默失败
- * =========================== */
- _safeScore: function(v) {
- if (typeof v !== 'number' || isNaN(v) || v === null) return 0
- return v
- },
- /* ===========================
- * Canvas 初始化
- * =========================== */
- initCanvas: function(cb) {
- var self = this
- uni.createSelectorQuery().in(this)
- .select('#' + this.canvasId)
- .fields({ node: true, size: true, rect: true })
- .exec(function(res) {
- // 兜底:回调内任何异常都必须走 cb(false),否则 mounted 的重试机制永远不触发 → 永久空白且无日志
- try {
- if (self._destroyed) return
- if (!res || !res[0]) {
- console.log('[RadarChart] 查询失败: 未找到 canvas 节点', self.canvasId)
- if (cb) cb(false)
- return
- }
- var info = res[0]
- if (!info.node) {
- console.log('[RadarChart] node 为空: canvas type=2d 节点获取失败', self.canvasId)
- if (cb) cb(false)
- return
- }
- var canvas = info.node
- var width = info.width
- var height = info.height
- if (!width || !height) {
- // 兜底:canvas 100%/100% 在 flex 容器中塌陷时 fields size 返回 0,改用 props 的 rpx 换算 px
- console.log('[RadarChart] 查询尺寸为 0,改用 props rpx 换算: width=' + width + ' height=' + height, self.canvasId)
- var winWidth = 375
- try {
- var winInfo = uni.getWindowInfo()
- winWidth = winInfo.windowWidth || 375
- } catch (e2) {}
- width = Math.round((self.width || 600) * winWidth / 750)
- height = Math.round((self.height || 600) * winWidth / 750)
- console.log('[RadarChart] 兜底尺寸: ' + width + 'x' + height, self.canvasId)
- }
- var pixelRatio = 2
- try {
- var system = uni.getWindowInfo()
- pixelRatio = system.pixelRatio || 2
- } catch (e) {
- console.log('[RadarChart] uni.getWindowInfo() 不可用,使用默认 pixelRatio=2', e)
- }
- canvas.width = width * pixelRatio
- canvas.height = height * pixelRatio
- var ctx = canvas.getContext('2d')
- if (!ctx) {
- console.log('[RadarChart] canvas.getContext(2d) 返回 null', self.canvasId)
- if (cb) cb(false)
- return
- }
- ctx.scale(pixelRatio, pixelRatio)
- self.ctx = ctx
- self._canvasNode = canvas
- self.canvasWidth = width
- self.canvasHeight = height
- self.dpr = pixelRatio
- self._canvasLeft = info.left || 0
- self._canvasTop = info.top || 0
- self.canvasReady = true
- console.log('[RadarChart] 初始化成功', 'size=' + width + 'x' + height, 'canvasId=' + self.canvasId, 'pixelRatio=' + pixelRatio)
- // 有分数则播放动画,否则只画网格
- if (Array.isArray(self.scores) && self.scores.length >= self.dimCount && self.dimCount >= 3) {
- self._startAnimation()
- } else {
- self._doRender()
- }
- if (cb) cb(true)
- } catch (e) {
- console.log('[RadarChart] initCanvas 回调异常', e)
- if (cb) cb(false)
- }
- })
- },
- /* ===========================
- * 渲染
- * =========================== */
- _doRender: function() {
- if (!this.ctx) { return }
- var ctx = this.ctx
- var w = this.canvasWidth
- var h = this.canvasHeight
- var cx = w / 2
- var cy = h / 2
- var outerR = Math.min(w, h) * 0.38
- var angles = this.vertexAngles
- ctx.clearRect(0, 0, w, h)
- var n = this.dimCount
- // 1. 绘制背景网格
- this._drawGrid(ctx, cx, cy, outerR, angles, n)
- // 2. 绘制家庭平均分(灰色虚线)
- if (this.avgScores && this.avgScores.length >= n && n >= 3) {
- this._drawAvgScoreArea(ctx, cx, cy, outerR, angles, n, this.avgFillColor || '#CBD5E1')
- }
- // 3. 绘制人群位置(蓝色虚线)
- if (this.populationScores && this.populationScores.length >= n && n >= 3) {
- this._drawPopulationScoreArea(ctx, cx, cy, outerR, angles, n)
- }
- // 4. 绘制用户分数区域
- this._drawScoreArea(ctx, cx, cy, outerR, angles, n)
- // 4. 绘制顶点标记
- this._drawVertices(ctx, cx, cy, outerR, angles, n)
- // 5. 绘制分数文字
- this._drawScoreText(ctx, cx, cy, outerR, angles, n)
- // 6. 绘制维度标签
- this._drawLabels(ctx, cx, cy, outerR, angles, n)
- // 7. 绘制中心标题
- this._drawCenterText(ctx, cx, cy)
- },
- /**
- * 绘制同心六边形网格 + 辐射线
- */
- _drawGrid: function(ctx, cx, cy, outerR, angles, n) {
- var levels = [0.25, 0.5, 0.75, 1.0]
- var gridColor = this.gridColor
- // 同心 N 边形
- ctx.strokeStyle = gridColor
- ctx.lineWidth = 1
- for (var li = 0; li < levels.length; li++) {
- var r = outerR * levels[li]
- ctx.beginPath()
- for (var ai2 = 0; ai2 < n; ai2++) {
- var vx_ = cx + r * Math.cos(angles[ai2])
- var vy_ = cy + r * Math.sin(angles[ai2])
- if (ai2 === 0) {
- ctx.moveTo(vx_, vy_)
- } else {
- ctx.lineTo(vx_, vy_)
- }
- }
- ctx.closePath()
- ctx.stroke()
- }
- // 辐射线(中心到各顶点)
- ctx.strokeStyle = gridColor
- ctx.lineWidth = 1
- ctx.beginPath()
- for (var ai3 = 0; ai3 < n; ai3++) {
- ctx.moveTo(cx, cy)
- ctx.lineTo(
- cx + outerR * Math.cos(angles[ai3]),
- cy + outerR * Math.sin(angles[ai3])
- )
- }
- ctx.stroke()
- },
- /**
- * 绘制分数填充多边形
- */
- _drawScoreArea: function(ctx, cx, cy, outerR, angles, n) {
- var scores = this._animScores
- // 防御:动画分数未就绪时直接用 props(避免首次渲染只有描边没有填充)
- if (!scores || scores.length < n) {
- scores = this.scores
- }
- if (!scores || scores.length < n) { return }
- // 构建顶点数据
- var points = []
- for (var i = 0; i < n; i++) {
- var score = this._safeScore(scores[i])
- var r = outerR * Math.max(0, Math.min(1, score / 100))
- points.push({
- x: cx + r * Math.cos(angles[i]),
- y: cy + r * Math.sin(angles[i])
- })
- }
- // 填充多边形
- ctx.beginPath()
- for (var pi = 0; pi < points.length; pi++) {
- if (pi === 0) {
- ctx.moveTo(points[pi].x, points[pi].y)
- } else {
- ctx.lineTo(points[pi].x, points[pi].y)
- }
- }
- ctx.closePath()
- // 实色半透明填充(微信 Canvas 2D 径向渐变 rgba 颜色停靠可能静默失败)
- var baseColor = this.fillColor || '#F97316'
- ctx.fillStyle = this.hexToRgba(baseColor, 0.15)
- ctx.fill()
- // 描边
- ctx.strokeStyle = this.fillColor || '#F97316'
- ctx.lineWidth = 2.5
- ctx.stroke()
- },
- /**
- * 绘制参考分数填充多边形(通用:家庭平均 / 人群位置)
- * @param {string} color - 描边颜色
- */
- _drawAvgScoreArea: function(ctx, cx, cy, outerR, angles, n, color) {
- var scores = this.avgScores
- if (!scores || scores.length < n) { return }
- // 构建顶点数据
- var points = []
- for (var i = 0; i < n; i++) {
- var score = scores[i] || 0
- var r = outerR * Math.max(0, Math.min(1, score / 100))
- points.push({
- x: cx + r * Math.cos(angles[i]),
- y: cy + r * Math.sin(angles[i])
- })
- }
- // 填充多边形(半透明)
- ctx.beginPath()
- for (var pi = 0; pi < points.length; pi++) {
- if (pi === 0) {
- ctx.moveTo(points[pi].x, points[pi].y)
- } else {
- ctx.lineTo(points[pi].x, points[pi].y)
- }
- }
- ctx.closePath()
- ctx.fillStyle = this.hexToRgba(color, 0.1)
- ctx.fill()
- // 描边(虚线)
- ctx.save()
- ctx.setLineDash([6, 4])
- ctx.strokeStyle = color
- ctx.lineWidth = 2
- ctx.stroke()
- ctx.restore()
- },
- /**
- * 绘制人群位置填充多边形(蓝色虚线,三层雷达的第二层)
- */
- _drawPopulationScoreArea: function(ctx, cx, cy, outerR, angles, n) {
- var scores = this.populationScores
- if (!scores || scores.length < n) { return }
- var color = this.populationColor || '#3B82F6'
- var points = []
- for (var i = 0; i < n; i++) {
- var score = this._safeScore(scores[i])
- var r = outerR * Math.max(0, Math.min(1, score / 100))
- points.push({
- x: cx + r * Math.cos(angles[i]),
- y: cy + r * Math.sin(angles[i])
- })
- }
- ctx.beginPath()
- for (var pi = 0; pi < points.length; pi++) {
- if (pi === 0) {
- ctx.moveTo(points[pi].x, points[pi].y)
- } else {
- ctx.lineTo(points[pi].x, points[pi].y)
- }
- }
- ctx.closePath()
- ctx.fillStyle = this.hexToRgba(color, 0.1)
- ctx.fill()
- ctx.save()
- ctx.setLineDash([6, 4])
- ctx.strokeStyle = color
- ctx.lineWidth = 2
- ctx.stroke()
- ctx.restore()
- },
- /**
- * 在各顶点绘制小圆点
- */
- _drawVertices: function(ctx, cx, cy, outerR, angles, n) {
- var scores = this._animScores
- if (!scores || scores.length < n) { return }
- ctx.fillStyle = this.fillColor || '#F97316'
- ctx.shadowColor = this.hexToRgba(this.fillColor || '#F97316', 0.3)
- ctx.shadowBlur = 6
- for (var i = 0; i < n; i++) {
- var score = this._safeScore(scores[i])
- var r = outerR * Math.max(0, Math.min(1, score / 100))
- var vx = cx + r * Math.cos(angles[i])
- var vy = cy + r * Math.sin(angles[i])
- ctx.beginPath()
- ctx.arc(vx, vy, 4.5, 0, 2 * Math.PI)
- ctx.fill()
- }
- ctx.shadowColor = 'transparent'
- ctx.shadowBlur = 0
- },
- /**
- * 在各顶点附近显示分值(百分比)
- */
- _drawScoreText: function(ctx, cx, cy, outerR, angles, n) {
- var scores = this._animScores
- if (!scores || scores.length < n) { return }
- ctx.font = '11px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillStyle = this.fillColor || '#F97316'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- for (var i = 0; i < n; i++) {
- var score = this._safeScore(scores[i])
- // 分数文字放在顶点向外偏移 16px 处
- var vertexR = outerR * Math.max(0, Math.min(1, score / 100))
- var textR = vertexR + 16
- var tx = cx + textR * Math.cos(angles[i])
- var ty = cy + textR * Math.sin(angles[i])
- var intScore = Math.round(score)
- ctx.fillText(intScore + '%', tx, ty)
- }
- },
- /**
- * 绘制维度名称标签(在六边形外围)
- */
- _drawLabels: function(ctx, cx, cy, outerR, angles, n) {
- var dims = this.dimensions
- if (!dims || dims.length < n) { return }
- var labelOffset = 30
- // 画布边缘留白(px),防止标签文字超出画布被裁切
- var pad = 4
- var w = this.canvasWidth
- var h = this.canvasHeight
- ctx.font = 'bold 13px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillStyle = this.labelColor || '#1E293B'
- for (var i = 0; i < n; i++) {
- var dim = dims[i]
- if (!dim) { continue }
- var label = dim.label || ''
- if (!label) { continue }
- var angle = angles[i]
- var labelR = outerR + labelOffset
- var lx = cx + labelR * Math.cos(angle)
- var ly = cy + labelR * Math.sin(angle)
- // 根据角度确定文字对齐方式
- var align = 'center'
- var baseline = 'middle'
- // 将角度归一化到 0 ~ 2PI
- var a = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
- if (a < Math.PI / 6 || a >= (11 * Math.PI) / 6) {
- // 右侧(-30°~30°)
- align = 'left'
- baseline = 'middle'
- } else if (a < (5 * Math.PI) / 6) {
- // 底部(30°~150°)
- align = 'center'
- baseline = 'top'
- } else if (a < (7 * Math.PI) / 6) {
- // 左侧(150°~210°)
- align = 'right'
- baseline = 'middle'
- } else {
- // 顶部(210°~330°)
- align = 'center'
- baseline = 'bottom'
- }
- // 估算文字宽高(13px 字体:中文≈13px/字,ASCII≈8px/字)
- var textW = 0
- for (var ci = 0; ci < label.length; ci++) {
- textW += label.charCodeAt(ci) > 255 ? 13 : 8
- }
- var textH = 13
- // 边界约束:将文字位置限制在画布内,避免顶/底/左右标签出界被裁切
- if (align === 'left') {
- lx = Math.min(lx, w - textW - pad)
- } else if (align === 'right') {
- lx = Math.max(lx, textW + pad)
- } else {
- lx = Math.max(textW / 2 + pad, Math.min(lx, w - textW / 2 - pad))
- }
- if (baseline === 'top') {
- ly = Math.min(ly, h - textH - pad)
- } else if (baseline === 'bottom') {
- ly = Math.max(ly, textH + pad)
- } else {
- ly = Math.max(textH / 2 + pad, Math.min(ly, h - textH / 2 - pad))
- }
- ctx.textAlign = align
- ctx.textBaseline = baseline
- ctx.fillText(label, lx, ly)
- }
- },
- /**
- * 在图表中心绘制孩子名字
- */
- _drawCenterText: function(ctx, cx, cy) {
- var name = this.childName
- if (!name) { return }
- ctx.font = 'bold 15px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
- ctx.fillStyle = '#F97316'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText(name, cx, cy)
- },
- /* ===========================
- * 动画控制
- * =========================== */
- _startAnimation: function() {
- var self = this
- var n = this.dimCount
- var scores = Array.isArray(this.scores) ? this.scores : []
- var target = []
- for (var i = 0; i < n; i++) {
- // 关键修复:后端可能返回 null score,这里归一化避免 NaN 传播导致 Canvas 静默失败
- target.push(self._safeScore(scores[i]))
- }
- var start = []
- for (var si = 0; si < n; si++) { start.push(0) }
- var startTime = Date.now()
- var duration = 600
- if (this._animId) {
- this._cancelAnim(this._animId)
- this._animId = null
- }
- function tick() {
- var elapsed = Date.now() - startTime
- var progress = Math.min(1, elapsed / duration)
- // ease-out cubic
- var eased = 1 - Math.pow(1 - progress, 3)
- for (var i2 = 0; i2 < n; i2++) {
- self._animScores[i2] = start[i2] + (target[i2] - start[i2]) * eased
- }
- self._doRender()
- if (progress < 1) {
- self._animId = self._requestAnim(tick)
- } else {
- // 确保最终为精确值
- for (var j2 = 0; j2 < n; j2++) {
- self._animScores[j2] = target[j2]
- }
- self._doRender()
- self._animId = null
- }
- }
- self._animScores = target.slice(0)
- self._animId = self._requestAnim(tick)
- },
- /**
- * 跨环境 requestAnimationFrame
- */
- _requestAnim: function(callback) {
- if (this._canvasNode && typeof this._canvasNode.requestAnimationFrame === 'function') {
- return this._canvasNode.requestAnimationFrame(callback)
- }
- // fallback
- return setTimeout(callback, 16)
- },
- /**
- * 跨环境 cancelAnimationFrame
- */
- _cancelAnim: function(id) {
- if (this._canvasNode && typeof this._canvasNode.cancelAnimationFrame === 'function') {
- this._canvasNode.cancelAnimationFrame(id)
- } else {
- clearTimeout(id)
- }
- },
- /* ===========================
- * 点击检测
- * =========================== */
- /** 将十六进制颜色转 rgba */
- hexToRgba: function(hex, alpha) {
- if (!hex) return 'rgba(0,0,0,' + alpha + ')'
- var h = hex.replace('#', '')
- var r = parseInt(h.substring(0, 2), 16)
- var g = parseInt(h.substring(2, 4), 16)
- var b = parseInt(h.substring(4, 6), 16)
- return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
- },
- onCanvasTap: function(e) {
- if (!this.canvasReady) { return }
- var pageX
- var pageY
- // 从事件中获取页面坐标
- if (e.detail && typeof e.detail.x === 'number') {
- pageX = e.detail.x
- pageY = e.detail.y
- } else if (e.touches && e.touches[0]) {
- pageX = e.touches[0].x || e.touches[0].clientX || 0
- pageY = e.touches[0].y || e.touches[0].clientY || 0
- } else if (e.changedTouches && e.changedTouches[0]) {
- pageX = e.changedTouches[0].x || e.changedTouches[0].clientX || 0
- pageY = e.changedTouches[0].y || e.changedTouches[0].clientY || 0
- } else {
- return
- }
- // 转为 canvas 相对坐标
- var relX = pageX - (this._canvasLeft || 0)
- var relY = pageY - (this._canvasTop || 0)
- if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) {
- return
- }
- var n = this.dimCount
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- var outerR = Math.min(this.canvasWidth, this.canvasHeight) * 0.38
- var angles = this.vertexAngles
- var labelOffset = 36
- var hitRadius = 44
- // 检测点击是否落在某个维度标签附近
- for (var i = 0; i < n; i++) {
- var dim = this.dimensions[i]
- if (!dim) { continue }
- var angle = angles[i]
- var labelR = outerR + labelOffset
- var lx = cx + labelR * Math.cos(angle)
- var ly = cy + labelR * Math.sin(angle)
- var dx = relX - lx
- var dy = relY - ly
- var dist = Math.sqrt(dx * dx + dy * dy)
- if (dist < hitRadius) {
- this.$emit('dimensionClick', dim.key || '')
- return
- }
- }
- }
- }
- }
- </script>
- <style scoped>
- .radar-chart-wrap {
- margin: 20rpx 30rpx;
- background: #FFFFFF;
- border-radius: 20rpx;
- box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
- overflow: hidden;
- display: flex;
- justify-content: center;
- align-items: center;
- padding: 16rpx 0;
- flex-direction: column;
- }
- .radar-container {
- position: relative;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- .radar-canvas {
- display: block;
- width: 100%;
- height: 100%;
- }
- .radar-tap-layer {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 2;
- }
- .radar-legend {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 24rpx;
- padding: 8rpx 24rpx 12rpx;
- }
- .legend-item {
- display: flex;
- align-items: center;
- gap: 6rpx;
- }
- .legend-dot {
- width: 16rpx;
- height: 16rpx;
- border-radius: 50%;
- flex-shrink: 0;
- }
- .legend-line {
- width: 32rpx;
- height: 4rpx;
- border-top: 3rpx dashed;
- flex-shrink: 0;
- }
- .legend-text {
- font-size: 22rpx;
- color: #64748B;
- font-weight: 500;
- }
- </style>
|