RadarChart.vue 26 KB

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