RadarChart.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. <template>
  2. <view class="radar-chart-wrap">
  3. <view
  4. class="radar-container"
  5. :style="containerStyle"
  6. >
  7. <canvas
  8. id="radarCanvas"
  9. type="2d"
  10. class="radar-canvas"
  11. ></canvas>
  12. <!-- 透明点击感应层 -->
  13. <view class="radar-tap-layer" @click="onCanvasTap"></view>
  14. </view>
  15. </view>
  16. </template>
  17. <script>
  18. /**
  19. * RadarChart - 六维雷达图 Canvas 组件
  20. *
  21. * 基于 HTML5 Canvas 2D 渲染,支持 DPR 适配、入场动画、点击事件。
  22. * 用于「心智」页面展示六维认知能力评分。
  23. *
  24. * 用法:
  25. * <RadarChart
  26. * :dimensions="dimList"
  27. * :scores="[85, 72, 68, 90, 55, 78]"
  28. * childName="小明"
  29. * @dimensionClick="onDimClick"
  30. * />
  31. */
  32. export default {
  33. props: {
  34. /** 六维标签 [{label, key}, ...],顺序:感知/专注/记忆/逻辑/空间/加工速度 */
  35. dimensions: {
  36. type: Array,
  37. default: function() {
  38. return []
  39. }
  40. },
  41. /** 六维分值 0-100,与 dimensions 顺序一一对应 */
  42. scores: {
  43. type: Array,
  44. default: function() {
  45. return []
  46. }
  47. },
  48. /** 填充颜色(可选,默认暖橙) */
  49. fillColor: {
  50. type: String,
  51. default: '#F97316'
  52. },
  53. /** 网格颜色(可选,默认浅灰) */
  54. gridColor: {
  55. type: String,
  56. default: '#E2E8F0'
  57. },
  58. /** 标签颜色(可选,默认深灰) */
  59. labelColor: {
  60. type: String,
  61. default: '#1E293B'
  62. },
  63. /** 图表标题(显示在中心) */
  64. childName: {
  65. type: String,
  66. default: ''
  67. },
  68. /** 图表宽度(rpx) */
  69. width: {
  70. type: Number,
  71. default: 600
  72. },
  73. /** 图表高度(rpx) */
  74. height: {
  75. type: Number,
  76. default: 600
  77. }
  78. },
  79. data: function() {
  80. return {
  81. canvasReady: false,
  82. canvasWidth: 0,
  83. canvasHeight: 0,
  84. ctx: null,
  85. dpr: 1,
  86. _canvasNode: null,
  87. _canvasLeft: 0,
  88. _canvasTop: 0,
  89. /** 动画插值中的分数 */
  90. _animScores: [],
  91. _animId: null
  92. }
  93. },
  94. computed: {
  95. /** 维度数量(灵活支持 N 维雷达图) */
  96. dimCount: function() {
  97. return this.dimensions ? this.dimensions.length : 0
  98. },
  99. /** 容器内联样式 */
  100. containerStyle: function() {
  101. return {
  102. width: this.width + 'rpx',
  103. height: this.height + 'rpx'
  104. }
  105. },
  106. /** N 边形的 N 个顶点的标准角度(弧度),从顶部顺时针 */
  107. vertexAngles: function() {
  108. var n = this.dimCount
  109. if (n < 3) return []
  110. var angles = []
  111. for (var i = 0; i < n; i++) {
  112. angles.push(-Math.PI / 2 + (i * 2 * Math.PI) / n)
  113. }
  114. return angles
  115. }
  116. },
  117. watch: {
  118. scores: {
  119. handler: function() {
  120. if (this.canvasReady) {
  121. this._startAnimation()
  122. }
  123. },
  124. deep: true
  125. }
  126. },
  127. mounted: function() {
  128. var self = this
  129. // 等 DOM 布局完成后初始化 Canvas
  130. setTimeout(function() {
  131. self.initCanvas()
  132. }, 100)
  133. },
  134. beforeDestroy: function() {
  135. if (this._animId) {
  136. this._cancelAnim(this._animId)
  137. this._animId = null
  138. }
  139. },
  140. methods: {
  141. /* ===========================
  142. * Canvas 初始化
  143. * =========================== */
  144. initCanvas: function() {
  145. var self = this
  146. uni.createSelectorQuery().in(this)
  147. .select('#radarCanvas')
  148. .fields({ node: true, size: true, rect: true })
  149. .exec(function(res) {
  150. if (!res || !res[0]) { return }
  151. var info = res[0]
  152. if (!info.node) { return }
  153. var canvas = info.node
  154. var width = info.width
  155. var height = info.height
  156. if (!width || !height) { return }
  157. var system = uni.getSystemInfoSync()
  158. var pixelRatio = system.pixelRatio || 2
  159. canvas.width = width * pixelRatio
  160. canvas.height = height * pixelRatio
  161. var ctx = canvas.getContext('2d')
  162. ctx.scale(pixelRatio, pixelRatio)
  163. self.ctx = ctx
  164. self._canvasNode = canvas
  165. self.canvasWidth = width
  166. self.canvasHeight = height
  167. self.dpr = pixelRatio
  168. self._canvasLeft = info.left || 0
  169. self._canvasTop = info.top || 0
  170. self.canvasReady = true
  171. // 有分数则播放动画,否则只画网格
  172. if (self.scores && self.scores.length >= self.dimCount && self.dimCount >= 3) {
  173. self._startAnimation()
  174. } else {
  175. self._doRender()
  176. }
  177. })
  178. },
  179. /* ===========================
  180. * 渲染
  181. * =========================== */
  182. _doRender: function() {
  183. if (!this.ctx) { return }
  184. var ctx = this.ctx
  185. var w = this.canvasWidth
  186. var h = this.canvasHeight
  187. var cx = w / 2
  188. var cy = h / 2
  189. var outerR = Math.min(w, h) * 0.38
  190. var angles = this.vertexAngles
  191. ctx.clearRect(0, 0, w, h)
  192. var n = this.dimCount
  193. // 1. 绘制背景网格
  194. this._drawGrid(ctx, cx, cy, outerR, angles, n)
  195. // 2. 绘制分数区域
  196. this._drawScoreArea(ctx, cx, cy, outerR, angles, n)
  197. // 3. 绘制顶点标记
  198. this._drawVertices(ctx, cx, cy, outerR, angles, n)
  199. // 4. 绘制分数文字
  200. this._drawScoreText(ctx, cx, cy, outerR, angles, n)
  201. // 5. 绘制维度标签
  202. this._drawLabels(ctx, cx, cy, outerR, angles, n)
  203. // 6. 绘制中心标题
  204. this._drawCenterText(ctx, cx, cy)
  205. },
  206. /**
  207. * 绘制同心六边形网格 + 辐射线
  208. */
  209. _drawGrid: function(ctx, cx, cy, outerR, angles, n) {
  210. var levels = [0.25, 0.5, 0.75, 1.0]
  211. var gridColor = this.gridColor
  212. // 同心 N 边形
  213. ctx.strokeStyle = gridColor
  214. ctx.lineWidth = 1
  215. for (var li = 0; li < levels.length; li++) {
  216. var r = outerR * levels[li]
  217. ctx.beginPath()
  218. for (var ai2 = 0; ai2 < n; ai2++) {
  219. var vx_ = cx + r * Math.cos(angles[ai2])
  220. var vy_ = cy + r * Math.sin(angles[ai2])
  221. if (ai2 === 0) {
  222. ctx.moveTo(vx_, vy_)
  223. } else {
  224. ctx.lineTo(vx_, vy_)
  225. }
  226. }
  227. ctx.closePath()
  228. ctx.stroke()
  229. }
  230. // 辐射线(中心到各顶点)
  231. ctx.strokeStyle = gridColor
  232. ctx.lineWidth = 1
  233. ctx.beginPath()
  234. for (var ai3 = 0; ai3 < n; ai3++) {
  235. ctx.moveTo(cx, cy)
  236. ctx.lineTo(
  237. cx + outerR * Math.cos(angles[ai3]),
  238. cy + outerR * Math.sin(angles[ai3])
  239. )
  240. }
  241. ctx.stroke()
  242. },
  243. /**
  244. * 绘制分数填充多边形
  245. */
  246. _drawScoreArea: function(ctx, cx, cy, outerR, angles, n) {
  247. var scores = this._animScores
  248. if (!scores || scores.length < n) { return }
  249. // 构建顶点数据
  250. var points = []
  251. for (var i = 0; i < n; i++) {
  252. var score = scores[i] || 0
  253. var r = outerR * Math.max(0, Math.min(1, score / 100))
  254. points.push({
  255. x: cx + r * Math.cos(angles[i]),
  256. y: cy + r * Math.sin(angles[i])
  257. })
  258. }
  259. // 填充多边形
  260. ctx.beginPath()
  261. for (var pi = 0; pi < points.length; pi++) {
  262. if (pi === 0) {
  263. ctx.moveTo(points[pi].x, points[pi].y)
  264. } else {
  265. ctx.lineTo(points[pi].x, points[pi].y)
  266. }
  267. }
  268. ctx.closePath()
  269. // 径向渐变填充(使用 fillColor)
  270. var baseColor = this.fillColor || '#F97316'
  271. var r1 = parseInt(baseColor.slice(1, 3), 16)
  272. var g1 = parseInt(baseColor.slice(3, 5), 16)
  273. var b1 = parseInt(baseColor.slice(5, 7), 16)
  274. var gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, outerR)
  275. gradient.addColorStop(0, 'rgba(' + r1 + ',' + g1 + ',' + b1 + ',0.12)')
  276. gradient.addColorStop(0.5, 'rgba(' + r1 + ',' + g1 + ',' + b1 + ',0.20)')
  277. gradient.addColorStop(1, 'rgba(' + r1 + ',' + g1 + ',' + b1 + ',0.32)')
  278. ctx.fillStyle = gradient
  279. ctx.fill()
  280. // 描边
  281. ctx.strokeStyle = this.fillColor || '#F97316'
  282. ctx.lineWidth = 2.5
  283. ctx.stroke()
  284. },
  285. /**
  286. * 在各顶点绘制小圆点
  287. */
  288. _drawVertices: function(ctx, cx, cy, outerR, angles, n) {
  289. var scores = this._animScores
  290. if (!scores || scores.length < n) { return }
  291. ctx.fillStyle = this.fillColor || '#F97316'
  292. ctx.shadowColor = this.hexToRgba(this.fillColor || '#F97316', 0.3)
  293. ctx.shadowBlur = 6
  294. for (var i = 0; i < n; i++) {
  295. var score = scores[i] || 0
  296. var r = outerR * Math.max(0, Math.min(1, score / 100))
  297. var vx = cx + r * Math.cos(angles[i])
  298. var vy = cy + r * Math.sin(angles[i])
  299. ctx.beginPath()
  300. ctx.arc(vx, vy, 4.5, 0, 2 * Math.PI)
  301. ctx.fill()
  302. }
  303. ctx.shadowColor = 'transparent'
  304. ctx.shadowBlur = 0
  305. },
  306. /**
  307. * 在各顶点附近显示分值(百分比)
  308. */
  309. _drawScoreText: function(ctx, cx, cy, outerR, angles, n) {
  310. var scores = this._animScores
  311. if (!scores || scores.length < n) { return }
  312. ctx.font = '11px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
  313. ctx.fillStyle = this.fillColor || '#F97316'
  314. ctx.textAlign = 'center'
  315. ctx.textBaseline = 'middle'
  316. for (var i = 0; i < n; i++) {
  317. var score = scores[i] || 0
  318. // 分数文字放在顶点向外偏移 16px 处
  319. var vertexR = outerR * Math.max(0, Math.min(1, score / 100))
  320. var textR = vertexR + 16
  321. var tx = cx + textR * Math.cos(angles[i])
  322. var ty = cy + textR * Math.sin(angles[i])
  323. var intScore = Math.round(score)
  324. ctx.fillText(intScore + '%', tx, ty)
  325. }
  326. },
  327. /**
  328. * 绘制维度名称标签(在六边形外围)
  329. */
  330. _drawLabels: function(ctx, cx, cy, outerR, angles, n) {
  331. var dims = this.dimensions
  332. if (!dims || dims.length < n) { return }
  333. var labelOffset = 36
  334. for (var i = 0; i < n; i++) {
  335. var dim = dims[i]
  336. if (!dim) { continue }
  337. var angle = angles[i]
  338. var labelR = outerR + labelOffset
  339. var lx = cx + labelR * Math.cos(angle)
  340. var ly = cy + labelR * Math.sin(angle)
  341. // 根据角度确定文字对齐方式
  342. var align = 'center'
  343. var baseline = 'middle'
  344. // 将角度归一化到 0 ~ 2PI
  345. var a = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)
  346. if (a < (1 * Math.PI) / 6 || a >= (11 * Math.PI) / 6) {
  347. // 右侧(-30°~30°):上右 / 下右
  348. if (a < Math.PI / 2) {
  349. // 下右
  350. align = 'left'
  351. baseline = 'top'
  352. } else {
  353. // 上右
  354. align = 'left'
  355. baseline = 'bottom'
  356. }
  357. } else if (a < (5 * Math.PI) / 6) {
  358. // 底部(30°~150°)
  359. align = 'center'
  360. baseline = 'top'
  361. } else if (a < (7 * Math.PI) / 6) {
  362. // 左侧(150°~210°)
  363. if (a < Math.PI) {
  364. // 下左
  365. align = 'right'
  366. baseline = 'top'
  367. } else {
  368. // 上左
  369. align = 'right'
  370. baseline = 'bottom'
  371. }
  372. } else {
  373. // 顶部(210°~330°)
  374. align = 'center'
  375. baseline = 'bottom'
  376. }
  377. ctx.font = 'bold 13px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
  378. ctx.fillStyle = this.labelColor || '#1E293B'
  379. ctx.textAlign = align
  380. ctx.textBaseline = baseline
  381. ctx.fillText(dim.label || '', lx, ly)
  382. }
  383. },
  384. /**
  385. * 在图表中心绘制孩子名字
  386. */
  387. _drawCenterText: function(ctx, cx, cy) {
  388. var name = this.childName
  389. if (!name) { return }
  390. ctx.font = 'bold 15px -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif'
  391. ctx.fillStyle = '#F97316'
  392. ctx.textAlign = 'center'
  393. ctx.textBaseline = 'middle'
  394. ctx.fillText(name, cx, cy)
  395. },
  396. /* ===========================
  397. * 动画控制
  398. * =========================== */
  399. _startAnimation: function() {
  400. var self = this
  401. var n = this.dimCount
  402. var target = this.scores.slice(0, n)
  403. var start = []
  404. for (var si = 0; si < n; si++) { start.push(0) }
  405. var startTime = Date.now()
  406. var duration = 600
  407. if (this._animId) {
  408. this._cancelAnim(this._animId)
  409. this._animId = null
  410. }
  411. function tick() {
  412. var elapsed = Date.now() - startTime
  413. var progress = Math.min(1, elapsed / duration)
  414. // ease-out cubic
  415. var eased = 1 - Math.pow(1 - progress, 3)
  416. for (var i2 = 0; i2 < n; i2++) {
  417. self._animScores[i2] = start[i2] + (target[i2] - start[i2]) * eased
  418. }
  419. self._doRender()
  420. if (progress < 1) {
  421. self._animId = self._requestAnim(tick)
  422. } else {
  423. // 确保最终为精确值
  424. for (var j2 = 0; j2 < n; j2++) {
  425. self._animScores[j2] = target[j2]
  426. }
  427. self._doRender()
  428. self._animId = null
  429. }
  430. }
  431. self._animId = self._requestAnim(tick)
  432. },
  433. /**
  434. * 跨环境 requestAnimationFrame
  435. */
  436. _requestAnim: function(callback) {
  437. if (this._canvasNode && typeof this._canvasNode.requestAnimationFrame === 'function') {
  438. return this._canvasNode.requestAnimationFrame(callback)
  439. }
  440. // fallback
  441. return setTimeout(callback, 16)
  442. },
  443. /**
  444. * 跨环境 cancelAnimationFrame
  445. */
  446. _cancelAnim: function(id) {
  447. if (this._canvasNode && typeof this._canvasNode.cancelAnimationFrame === 'function') {
  448. this._canvasNode.cancelAnimationFrame(id)
  449. } else {
  450. clearTimeout(id)
  451. }
  452. },
  453. /* ===========================
  454. * 点击检测
  455. * =========================== */
  456. /** 将十六进制颜色转 rgba */
  457. hexToRgba: function(hex, alpha) {
  458. if (!hex) return 'rgba(0,0,0,' + alpha + ')'
  459. var h = hex.replace('#', '')
  460. var r = parseInt(h.substring(0, 2), 16)
  461. var g = parseInt(h.substring(2, 4), 16)
  462. var b = parseInt(h.substring(4, 6), 16)
  463. return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
  464. },
  465. onCanvasTap: function(e) {
  466. if (!this.canvasReady) { return }
  467. var pageX
  468. var pageY
  469. // 从事件中获取页面坐标
  470. if (e.detail && typeof e.detail.x === 'number') {
  471. pageX = e.detail.x
  472. pageY = e.detail.y
  473. } else if (e.touches && e.touches[0]) {
  474. pageX = e.touches[0].x || e.touches[0].clientX || 0
  475. pageY = e.touches[0].y || e.touches[0].clientY || 0
  476. } else if (e.changedTouches && e.changedTouches[0]) {
  477. pageX = e.changedTouches[0].x || e.changedTouches[0].clientX || 0
  478. pageY = e.changedTouches[0].y || e.changedTouches[0].clientY || 0
  479. } else {
  480. return
  481. }
  482. // 转为 canvas 相对坐标
  483. var relX = pageX - (this._canvasLeft || 0)
  484. var relY = pageY - (this._canvasTop || 0)
  485. if (relX < 0 || relY < 0 || relX > this.canvasWidth || relY > this.canvasHeight) {
  486. return
  487. }
  488. var n = this.dimCount
  489. var cx = this.canvasWidth / 2
  490. var cy = this.canvasHeight / 2
  491. var outerR = Math.min(this.canvasWidth, this.canvasHeight) * 0.38
  492. var angles = this.vertexAngles
  493. var labelOffset = 36
  494. var hitRadius = 44
  495. // 检测点击是否落在某个维度标签附近
  496. for (var i = 0; i < n; i++) {
  497. var dim = this.dimensions[i]
  498. if (!dim) { continue }
  499. var angle = angles[i]
  500. var labelR = outerR + labelOffset
  501. var lx = cx + labelR * Math.cos(angle)
  502. var ly = cy + labelR * Math.sin(angle)
  503. var dx = relX - lx
  504. var dy = relY - ly
  505. var dist = Math.sqrt(dx * dx + dy * dy)
  506. if (dist < hitRadius) {
  507. this.$emit('dimensionClick', dim.key || '')
  508. return
  509. }
  510. }
  511. }
  512. }
  513. }
  514. </script>
  515. <style scoped>
  516. .radar-chart-wrap {
  517. margin: 20rpx 30rpx;
  518. background: #FFFFFF;
  519. border-radius: 20rpx;
  520. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
  521. overflow: hidden;
  522. display: flex;
  523. justify-content: center;
  524. align-items: center;
  525. padding: 16rpx 0;
  526. }
  527. .radar-container {
  528. position: relative;
  529. display: flex;
  530. justify-content: center;
  531. align-items: center;
  532. }
  533. .radar-canvas {
  534. display: block;
  535. width: 100%;
  536. height: 100%;
  537. }
  538. .radar-tap-layer {
  539. position: absolute;
  540. top: 0;
  541. left: 0;
  542. right: 0;
  543. bottom: 0;
  544. z-index: 2;
  545. }
  546. </style>