RadarChart.vue 27 KB

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