RadarChart.vue 15 KB

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