RadarChart.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787
  1. <template>
  2. <view class="radar-chart-wrap">
  3. <view
  4. class="radar-container"
  5. :style="containerStyle"
  6. >
  7. <canvas
  8. :id="canvasId"
  9. type="2d"
  10. class="radar-canvas"
  11. :style="canvasStyle"
  12. ></canvas>
  13. <!-- 透明点击感应层 -->
  14. <view class="radar-tap-layer" @click="onCanvasTap"></view>
  15. </view>
  16. </view>
  17. </template>
  18. <script>
  19. /**
  20. * RadarChart - 六维雷达图 Canvas 组件
  21. *
  22. * 基于 HTML5 Canvas 2D 渲染,支持 DPR 适配、入场动画、点击事件。
  23. * 用于「心智」页面展示六维认知能力评分。
  24. *
  25. * 用法:
  26. * <RadarChart
  27. * :dimensions="dimList"
  28. * :scores="[85, 72, 68, 90, 55, 78]"
  29. * childName="小明"
  30. * @dimensionClick="onDimClick"
  31. * />
  32. */
  33. export default {
  34. props: {
  35. /** 六维标签 [{label, key}, ...],顺序:感知/专注/记忆/逻辑/空间/加工速度 */
  36. dimensions: {
  37. type: Array,
  38. default: function() {
  39. return []
  40. }
  41. },
  42. /** 六维分值 0-100,与 dimensions 顺序一一对应 */
  43. scores: {
  44. type: Array,
  45. default: function() {
  46. return []
  47. }
  48. },
  49. /** 填充颜色(可选,默认暖橙) */
  50. fillColor: {
  51. type: String,
  52. default: '#F97316'
  53. },
  54. /** 网格颜色(可选,默认浅灰) */
  55. gridColor: {
  56. type: String,
  57. default: '#E2E8F0'
  58. },
  59. /** 标签颜色(可选,默认深灰) */
  60. labelColor: {
  61. type: String,
  62. default: '#1E293B'
  63. },
  64. /** 图表标题(显示在中心) */
  65. childName: {
  66. type: String,
  67. default: ''
  68. },
  69. /** 图表宽度(rpx) */
  70. width: {
  71. type: Number,
  72. default: 600
  73. },
  74. /** 图表高度(rpx) */
  75. height: {
  76. type: Number,
  77. default: 600
  78. },
  79. /** 平均值分数 0-100,与 dimensions 顺序一一对应(可选,显示为灰色虚线) */
  80. avgScores: {
  81. type: Array,
  82. default: function() {
  83. return []
  84. }
  85. },
  86. /** 平均值填充颜色(可选,默认灰色) */
  87. avgFillColor: {
  88. type: String,
  89. default: '#CBD5E1'
  90. },
  91. /** 平均值网格颜色(可选,默认浅灰) */
  92. avgGridColor: {
  93. type: String,
  94. default: '#E2E8F0'
  95. },
  96. /** 平均值标签颜色(可选,默认浅灰) */
  97. avgLabelColor: {
  98. type: String,
  99. default: '#94A3B8'
  100. }
  101. },
  102. data: function() {
  103. return {
  104. canvasReady: false,
  105. canvasWidth: 0,
  106. canvasHeight: 0,
  107. ctx: null,
  108. dpr: 1,
  109. _canvasNode: null,
  110. _canvasLeft: 0,
  111. _canvasTop: 0,
  112. /** 动画插值中的分数 */
  113. _animScores: [],
  114. _animId: null,
  115. /** 当前实例唯一的 canvas id(同页多实例时避免 select('#radarCanvas') 命中错误节点) */
  116. canvasId: 'radarCanvas_' + (this._uid || 0),
  117. /** 组件已销毁标志:mounted 的延迟初始化与重试链需据此停止,避免销毁后继续查询节点刷错误日志 */
  118. _destroyed: false,
  119. /** mounted 延迟初始化定时器引用(beforeDestroy 时清理) */
  120. _initTimer: null
  121. }
  122. },
  123. computed: {
  124. /** 维度数量(灵活支持 N 维雷达图) */
  125. dimCount: function() {
  126. return this.dimensions ? this.dimensions.length : 0
  127. },
  128. /** 容器内联样式(字符串形式,微信小程序 style 绑定只可靠支持字符串) */
  129. containerStyle: function() {
  130. return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx'
  131. },
  132. /** canvas 内联样式:显式撑开尺寸,避免 100%/100% 在 flex 容器中塌陷为 0 */
  133. canvasStyle: function() {
  134. return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx'
  135. },
  136. /** N 边形的 N 个顶点的标准角度(弧度),从顶部顺时针 */
  137. vertexAngles: function() {
  138. var n = this.dimCount
  139. if (n < 3) return []
  140. var angles = []
  141. for (var i = 0; i < n; i++) {
  142. angles.push(-Math.PI / 2 + (i * 2 * Math.PI) / n)
  143. }
  144. return angles
  145. }
  146. },
  147. watch: {
  148. scores: {
  149. handler: function(val) {
  150. if (!Array.isArray(val)) return
  151. if (this.canvasReady) {
  152. this._startAnimation()
  153. }
  154. },
  155. deep: true
  156. },
  157. avgScores: {
  158. handler: function() {
  159. if (this.canvasReady) {
  160. this._doRender()
  161. }
  162. },
  163. deep: true
  164. }
  165. },
  166. mounted: function() {
  167. var self = this
  168. // 等 DOM 布局完成后初始化 Canvas(带重试,应对微信小程序首次渲染 Canvas 节点延迟)
  169. var retryCount = 0
  170. var maxRetry = 5
  171. function tryInit() {
  172. if (self._destroyed) return
  173. self.initCanvas(function(success) {
  174. if (self._destroyed) return
  175. if (!success && retryCount < maxRetry) {
  176. retryCount++
  177. self._initTimer = setTimeout(tryInit, 60)
  178. } else if (!success) {
  179. console.log('[RadarChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
  180. }
  181. })
  182. }
  183. self._initTimer = setTimeout(tryInit, 100)
  184. },
  185. beforeDestroy: function() {
  186. this._destroyed = true
  187. if (this._initTimer) {
  188. clearTimeout(this._initTimer)
  189. this._initTimer = null
  190. }
  191. if (this._animId) {
  192. this._cancelAnim(this._animId)
  193. this._animId = null
  194. }
  195. },
  196. methods: {
  197. /* ===========================
  198. * 工具:分数归一化(null/undefined/NaN 一律按 0 处理)
  199. * 防止后端返回 null score 时 Canvas 计算 NaN 导致 moveTo/lineTo 静默失败
  200. * =========================== */
  201. _safeScore: function(v) {
  202. if (typeof v !== 'number' || isNaN(v) || v === null) return 0
  203. return v
  204. },
  205. /* ===========================
  206. * Canvas 初始化
  207. * =========================== */
  208. initCanvas: function(cb) {
  209. var self = this
  210. uni.createSelectorQuery().in(this)
  211. .select('#' + this.canvasId)
  212. .fields({ node: true, size: true, rect: true })
  213. .exec(function(res) {
  214. // 兜底:回调内任何异常都必须走 cb(false),否则 mounted 的重试机制永远不触发 → 永久空白且无日志
  215. try {
  216. if (self._destroyed) return
  217. if (!res || !res[0]) {
  218. console.log('[RadarChart] 查询失败: 未找到 canvas 节点', self.canvasId)
  219. if (cb) cb(false)
  220. return
  221. }
  222. var info = res[0]
  223. if (!info.node) {
  224. console.log('[RadarChart] node 为空: canvas type=2d 节点获取失败', self.canvasId)
  225. if (cb) cb(false)
  226. return
  227. }
  228. var canvas = info.node
  229. var width = info.width
  230. var height = info.height
  231. if (!width || !height) {
  232. // 兜底:canvas 100%/100% 在 flex 容器中塌陷时 fields size 返回 0,改用 props 的 rpx 换算 px
  233. console.log('[RadarChart] 查询尺寸为 0,改用 props rpx 换算: width=' + width + ' height=' + height, self.canvasId)
  234. var winWidth = 375
  235. try {
  236. var winInfo = uni.getWindowInfo()
  237. winWidth = winInfo.windowWidth || 375
  238. } catch (e2) {}
  239. width = Math.round((self.width || 600) * winWidth / 750)
  240. height = Math.round((self.height || 600) * winWidth / 750)
  241. console.log('[RadarChart] 兜底尺寸: ' + width + 'x' + height, self.canvasId)
  242. }
  243. var pixelRatio = 2
  244. try {
  245. var system = uni.getWindowInfo()
  246. pixelRatio = system.pixelRatio || 2
  247. } catch (e) {
  248. console.log('[RadarChart] uni.getWindowInfo() 不可用,使用默认 pixelRatio=2', e)
  249. }
  250. canvas.width = width * pixelRatio
  251. canvas.height = height * pixelRatio
  252. var ctx = canvas.getContext('2d')
  253. if (!ctx) {
  254. console.log('[RadarChart] canvas.getContext(2d) 返回 null', self.canvasId)
  255. if (cb) cb(false)
  256. return
  257. }
  258. ctx.scale(pixelRatio, pixelRatio)
  259. self.ctx = ctx
  260. self._canvasNode = canvas
  261. self.canvasWidth = width
  262. self.canvasHeight = height
  263. self.dpr = pixelRatio
  264. self._canvasLeft = info.left || 0
  265. self._canvasTop = info.top || 0
  266. self.canvasReady = true
  267. console.log('[RadarChart] 初始化成功', 'size=' + width + 'x' + height, 'canvasId=' + self.canvasId, 'pixelRatio=' + pixelRatio)
  268. // 有分数则播放动画,否则只画网格
  269. if (Array.isArray(self.scores) && self.scores.length >= self.dimCount && self.dimCount >= 3) {
  270. self._startAnimation()
  271. } else {
  272. self._doRender()
  273. }
  274. if (cb) cb(true)
  275. } catch (e) {
  276. console.log('[RadarChart] initCanvas 回调异常', e)
  277. if (cb) cb(false)
  278. }
  279. })
  280. },
  281. /* ===========================
  282. * 渲染
  283. * =========================== */
  284. _doRender: function() {
  285. if (!this.ctx) { return }
  286. var ctx = this.ctx
  287. var w = this.canvasWidth
  288. var h = this.canvasHeight
  289. var cx = w / 2
  290. var cy = h / 2
  291. var outerR = Math.min(w, h) * 0.38
  292. var angles = this.vertexAngles
  293. ctx.clearRect(0, 0, w, h)
  294. var n = this.dimCount
  295. // 1. 绘制背景网格
  296. this._drawGrid(ctx, cx, cy, outerR, angles, n)
  297. // 2. 如果有平均值,先画平均值(灰色虚线)
  298. if (this.avgScores && this.avgScores.length >= n && n >= 3) {
  299. this._drawAvgScoreArea(ctx, cx, cy, outerR, angles, n)
  300. }
  301. // 3. 绘制用户分数区域
  302. this._drawScoreArea(ctx, cx, cy, outerR, angles, n)
  303. // 4. 绘制顶点标记
  304. this._drawVertices(ctx, cx, cy, outerR, angles, n)
  305. // 5. 绘制分数文字
  306. this._drawScoreText(ctx, cx, cy, outerR, angles, n)
  307. // 6. 绘制维度标签
  308. this._drawLabels(ctx, cx, cy, outerR, angles, n)
  309. // 7. 绘制中心标题
  310. this._drawCenterText(ctx, cx, cy)
  311. },
  312. /**
  313. * 绘制同心六边形网格 + 辐射线
  314. */
  315. _drawGrid: function(ctx, cx, cy, outerR, angles, n) {
  316. var levels = [0.25, 0.5, 0.75, 1.0]
  317. var gridColor = this.gridColor
  318. // 同心 N 边形
  319. ctx.strokeStyle = gridColor
  320. ctx.lineWidth = 1
  321. for (var li = 0; li < levels.length; li++) {
  322. var r = outerR * levels[li]
  323. ctx.beginPath()
  324. for (var ai2 = 0; ai2 < n; ai2++) {
  325. var vx_ = cx + r * Math.cos(angles[ai2])
  326. var vy_ = cy + r * Math.sin(angles[ai2])
  327. if (ai2 === 0) {
  328. ctx.moveTo(vx_, vy_)
  329. } else {
  330. ctx.lineTo(vx_, vy_)
  331. }
  332. }
  333. ctx.closePath()
  334. ctx.stroke()
  335. }
  336. // 辐射线(中心到各顶点)
  337. ctx.strokeStyle = gridColor
  338. ctx.lineWidth = 1
  339. ctx.beginPath()
  340. for (var ai3 = 0; ai3 < n; ai3++) {
  341. ctx.moveTo(cx, cy)
  342. ctx.lineTo(
  343. cx + outerR * Math.cos(angles[ai3]),
  344. cy + outerR * Math.sin(angles[ai3])
  345. )
  346. }
  347. ctx.stroke()
  348. },
  349. /**
  350. * 绘制分数填充多边形
  351. */
  352. _drawScoreArea: function(ctx, cx, cy, outerR, angles, n) {
  353. var scores = this._animScores
  354. // 防御:动画分数未就绪时直接用 props(避免首次渲染只有描边没有填充)
  355. if (!scores || scores.length < n) {
  356. scores = this.scores
  357. }
  358. if (!scores || scores.length < n) { return }
  359. // 构建顶点数据
  360. var points = []
  361. for (var i = 0; i < n; i++) {
  362. var score = this._safeScore(scores[i])
  363. var r = outerR * Math.max(0, Math.min(1, score / 100))
  364. points.push({
  365. x: cx + r * Math.cos(angles[i]),
  366. y: cy + r * Math.sin(angles[i])
  367. })
  368. }
  369. // 填充多边形
  370. ctx.beginPath()
  371. for (var pi = 0; pi < points.length; pi++) {
  372. if (pi === 0) {
  373. ctx.moveTo(points[pi].x, points[pi].y)
  374. } else {
  375. ctx.lineTo(points[pi].x, points[pi].y)
  376. }
  377. }
  378. ctx.closePath()
  379. // 实色半透明填充(微信 Canvas 2D 径向渐变 rgba 颜色停靠可能静默失败)
  380. var baseColor = this.fillColor || '#F97316'
  381. ctx.fillStyle = this.hexToRgba(baseColor, 0.15)
  382. ctx.fill()
  383. // 描边
  384. ctx.strokeStyle = this.fillColor || '#F97316'
  385. ctx.lineWidth = 2.5
  386. ctx.stroke()
  387. },
  388. /**
  389. * 绘制平均值填充多边形(灰色虚线)
  390. */
  391. _drawAvgScoreArea: function(ctx, cx, cy, outerR, angles, n) {
  392. var scores = this.avgScores
  393. if (!scores || scores.length < n) { return }
  394. // 构建顶点数据
  395. var points = []
  396. for (var i = 0; i < n; i++) {
  397. var score = scores[i] || 0
  398. var r = outerR * Math.max(0, Math.min(1, score / 100))
  399. points.push({
  400. x: cx + r * Math.cos(angles[i]),
  401. y: cy + r * Math.sin(angles[i])
  402. })
  403. }
  404. // 填充多边形(半透明灰色)
  405. ctx.beginPath()
  406. for (var pi = 0; pi < points.length; pi++) {
  407. if (pi === 0) {
  408. ctx.moveTo(points[pi].x, points[pi].y)
  409. } else {
  410. ctx.lineTo(points[pi].x, points[pi].y)
  411. }
  412. }
  413. ctx.closePath()
  414. var baseColor = this.avgFillColor || '#CBD5E1'
  415. ctx.fillStyle = 'rgba(203,213,225,0.15)'
  416. ctx.fill()
  417. // 描边(灰色虚线)
  418. ctx.save()
  419. ctx.setLineDash([6, 4])
  420. ctx.strokeStyle = baseColor
  421. ctx.lineWidth = 2
  422. ctx.stroke()
  423. ctx.restore()
  424. },
  425. /**
  426. * 在各顶点绘制小圆点
  427. */
  428. _drawVertices: function(ctx, cx, cy, outerR, angles, n) {
  429. var scores = this._animScores
  430. if (!scores || scores.length < n) { return }
  431. ctx.fillStyle = this.fillColor || '#F97316'
  432. ctx.shadowColor = this.hexToRgba(this.fillColor || '#F97316', 0.3)
  433. ctx.shadowBlur = 6
  434. for (var i = 0; i < n; i++) {
  435. var score = this._safeScore(scores[i])
  436. var r = outerR * Math.max(0, Math.min(1, score / 100))
  437. var vx = cx + r * Math.cos(angles[i])
  438. var vy = cy + r * Math.sin(angles[i])
  439. ctx.beginPath()
  440. ctx.arc(vx, vy, 4.5, 0, 2 * Math.PI)
  441. ctx.fill()
  442. }
  443. ctx.shadowColor = 'transparent'
  444. ctx.shadowBlur = 0
  445. },
  446. /**
  447. * 在各顶点附近显示分值(百分比)
  448. */
  449. _drawScoreText: function(ctx, cx, cy, outerR, angles, n) {
  450. var scores = this._animScores
  451. if (!scores || scores.length < n) { return }
  452. ctx.font = '11px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
  453. ctx.fillStyle = this.fillColor || '#F97316'
  454. ctx.textAlign = 'center'
  455. ctx.textBaseline = 'middle'
  456. for (var i = 0; i < n; i++) {
  457. var score = this._safeScore(scores[i])
  458. // 分数文字放在顶点向外偏移 16px 处
  459. var vertexR = outerR * Math.max(0, Math.min(1, score / 100))
  460. var textR = vertexR + 16
  461. var tx = cx + textR * Math.cos(angles[i])
  462. var ty = cy + textR * Math.sin(angles[i])
  463. var intScore = Math.round(score)
  464. ctx.fillText(intScore + '%', tx, ty)
  465. }
  466. },
  467. /**
  468. * 绘制维度名称标签(在六边形外围)
  469. */
  470. _drawLabels: function(ctx, cx, cy, outerR, angles, n) {
  471. var dims = this.dimensions
  472. if (!dims || dims.length < n) { return }
  473. var labelOffset = 30
  474. // 画布边缘留白(px),防止标签文字超出画布被裁切
  475. var pad = 4
  476. var w = this.canvasWidth
  477. var h = this.canvasHeight
  478. ctx.font = 'bold 13px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
  479. ctx.fillStyle = this.labelColor || '#1E293B'
  480. for (var i = 0; i < n; i++) {
  481. var dim = dims[i]
  482. if (!dim) { continue }
  483. var label = dim.label || ''
  484. if (!label) { continue }
  485. var angle = angles[i]
  486. var labelR = outerR + labelOffset
  487. var lx = cx + labelR * Math.cos(angle)
  488. var ly = cy + labelR * Math.sin(angle)
  489. // 根据角度确定文字对齐方式
  490. var align = 'center'
  491. var baseline = 'middle'
  492. // 将角度归一化到 0 ~ 2PI
  493. var a = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
  494. if (a < Math.PI / 6 || a >= (11 * Math.PI) / 6) {
  495. // 右侧(-30°~30°)
  496. align = 'left'
  497. baseline = 'middle'
  498. } else if (a < (5 * Math.PI) / 6) {
  499. // 底部(30°~150°)
  500. align = 'center'
  501. baseline = 'top'
  502. } else if (a < (7 * Math.PI) / 6) {
  503. // 左侧(150°~210°)
  504. align = 'right'
  505. baseline = 'middle'
  506. } else {
  507. // 顶部(210°~330°)
  508. align = 'center'
  509. baseline = 'bottom'
  510. }
  511. // 估算文字宽高(13px 字体:中文≈13px/字,ASCII≈8px/字)
  512. var textW = 0
  513. for (var ci = 0; ci < label.length; ci++) {
  514. textW += label.charCodeAt(ci) > 255 ? 13 : 8
  515. }
  516. var textH = 13
  517. // 边界约束:将文字位置限制在画布内,避免顶/底/左右标签出界被裁切
  518. if (align === 'left') {
  519. lx = Math.min(lx, w - textW - pad)
  520. } else if (align === 'right') {
  521. lx = Math.max(lx, textW + pad)
  522. } else {
  523. lx = Math.max(textW / 2 + pad, Math.min(lx, w - textW / 2 - pad))
  524. }
  525. if (baseline === 'top') {
  526. ly = Math.min(ly, h - textH - pad)
  527. } else if (baseline === 'bottom') {
  528. ly = Math.max(ly, textH + pad)
  529. } else {
  530. ly = Math.max(textH / 2 + pad, Math.min(ly, h - textH / 2 - pad))
  531. }
  532. ctx.textAlign = align
  533. ctx.textBaseline = baseline
  534. ctx.fillText(label, lx, ly)
  535. }
  536. },
  537. /**
  538. * 在图表中心绘制孩子名字
  539. */
  540. _drawCenterText: function(ctx, cx, cy) {
  541. var name = this.childName
  542. if (!name) { return }
  543. ctx.font = 'bold 15px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
  544. ctx.fillStyle = '#F97316'
  545. ctx.textAlign = 'center'
  546. ctx.textBaseline = 'middle'
  547. ctx.fillText(name, cx, cy)
  548. },
  549. /* ===========================
  550. * 动画控制
  551. * =========================== */
  552. _startAnimation: function() {
  553. var self = this
  554. var n = this.dimCount
  555. var scores = Array.isArray(this.scores) ? this.scores : []
  556. var target = []
  557. for (var i = 0; i < n; i++) {
  558. // 关键修复:后端可能返回 null score,这里归一化避免 NaN 传播导致 Canvas 静默失败
  559. target.push(self._safeScore(scores[i]))
  560. }
  561. var start = []
  562. for (var si = 0; si < n; si++) { start.push(0) }
  563. var startTime = Date.now()
  564. var duration = 600
  565. if (this._animId) {
  566. this._cancelAnim(this._animId)
  567. this._animId = null
  568. }
  569. function tick() {
  570. var elapsed = Date.now() - startTime
  571. var progress = Math.min(1, elapsed / duration)
  572. // ease-out cubic
  573. var eased = 1 - Math.pow(1 - progress, 3)
  574. for (var i2 = 0; i2 < n; i2++) {
  575. self._animScores[i2] = start[i2] + (target[i2] - start[i2]) * eased
  576. }
  577. self._doRender()
  578. if (progress < 1) {
  579. self._animId = self._requestAnim(tick)
  580. } else {
  581. // 确保最终为精确值
  582. for (var j2 = 0; j2 < n; j2++) {
  583. self._animScores[j2] = target[j2]
  584. }
  585. self._doRender()
  586. self._animId = null
  587. }
  588. }
  589. self._animScores = target.slice(0)
  590. self._animId = self._requestAnim(tick)
  591. },
  592. /**
  593. * 跨环境 requestAnimationFrame
  594. */
  595. _requestAnim: function(callback) {
  596. if (this._canvasNode && typeof this._canvasNode.requestAnimationFrame === 'function') {
  597. return this._canvasNode.requestAnimationFrame(callback)
  598. }
  599. // fallback
  600. return setTimeout(callback, 16)
  601. },
  602. /**
  603. * 跨环境 cancelAnimationFrame
  604. */
  605. _cancelAnim: function(id) {
  606. if (this._canvasNode && typeof this._canvasNode.cancelAnimationFrame === 'function') {
  607. this._canvasNode.cancelAnimationFrame(id)
  608. } else {
  609. clearTimeout(id)
  610. }
  611. },
  612. /* ===========================
  613. * 点击检测
  614. * =========================== */
  615. /** 将十六进制颜色转 rgba */
  616. hexToRgba: function(hex, alpha) {
  617. if (!hex) return 'rgba(0,0,0,' + alpha + ')'
  618. var h = hex.replace('#', '')
  619. var r = parseInt(h.substring(0, 2), 16)
  620. var g = parseInt(h.substring(2, 4), 16)
  621. var b = parseInt(h.substring(4, 6), 16)
  622. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  623. },
  624. onCanvasTap: function(e) {
  625. if (!this.canvasReady) { return }
  626. var pageX
  627. var pageY
  628. // 从事件中获取页面坐标
  629. if (e.detail && typeof e.detail.x === 'number') {
  630. pageX = e.detail.x
  631. pageY = e.detail.y
  632. } else if (e.touches && e.touches[0]) {
  633. pageX = e.touches[0].x || e.touches[0].clientX || 0
  634. pageY = e.touches[0].y || e.touches[0].clientY || 0
  635. } else if (e.changedTouches && e.changedTouches[0]) {
  636. pageX = e.changedTouches[0].x || e.changedTouches[0].clientX || 0
  637. pageY = e.changedTouches[0].y || e.changedTouches[0].clientY || 0
  638. } else {
  639. return
  640. }
  641. // 转为 canvas 相对坐标
  642. var relX = pageX - (this._canvasLeft || 0)
  643. var relY = pageY - (this._canvasTop || 0)
  644. if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) {
  645. return
  646. }
  647. var n = this.dimCount
  648. var cx = this.canvasWidth / 2
  649. var cy = this.canvasHeight / 2
  650. var outerR = Math.min(this.canvasWidth, this.canvasHeight) * 0.38
  651. var angles = this.vertexAngles
  652. var labelOffset = 36
  653. var hitRadius = 44
  654. // 检测点击是否落在某个维度标签附近
  655. for (var i = 0; i < n; i++) {
  656. var dim = this.dimensions[i]
  657. if (!dim) { continue }
  658. var angle = angles[i]
  659. var labelR = outerR + labelOffset
  660. var lx = cx + labelR * Math.cos(angle)
  661. var ly = cy + labelR * Math.sin(angle)
  662. var dx = relX - lx
  663. var dy = relY - ly
  664. var dist = Math.sqrt(dx * dx + dy * dy)
  665. if (dist < hitRadius) {
  666. this.$emit('dimensionClick', dim.key || '')
  667. return
  668. }
  669. }
  670. }
  671. }
  672. }
  673. </script>
  674. <style scoped>
  675. .radar-chart-wrap {
  676. margin: 20rpx 30rpx;
  677. background: #FFFFFF;
  678. border-radius: 20rpx;
  679. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
  680. overflow: hidden;
  681. display: flex;
  682. justify-content: center;
  683. align-items: center;
  684. padding: 16rpx 0;
  685. }
  686. .radar-container {
  687. position: relative;
  688. display: flex;
  689. justify-content: center;
  690. align-items: center;
  691. }
  692. .radar-canvas {
  693. display: block;
  694. width: 100%;
  695. height: 100%;
  696. }
  697. .radar-tap-layer {
  698. position: absolute;
  699. top: 0;
  700. left: 0;
  701. right: 0;
  702. bottom: 0;
  703. z-index: 2;
  704. }
  705. </style>