TrendChart.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. <template>
  2. <view class="trend-chart-wrap">
  3. <view class="trend-container" :style="containerStyle">
  4. <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
  5. <view class="trend-tap-layer" @click="onCanvasTap"></view>
  6. </view>
  7. <view class="trend-legend" v-if="series.length > 1">
  8. <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
  9. <view class="legend-line" :style="'border-color:' + s.color"></view>
  10. <text class="legend-text">{{ s.name }}</text>
  11. </view>
  12. </view>
  13. </view>
  14. </template>
  15. <script>
  16. import { BrandColors } from '../utils/brand-colors.js'
  17. /**
  18. * TrendChart - 多序列折线图 Canvas 组件
  19. * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
  20. * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
  21. */
  22. export default {
  23. props: {
  24. /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
  25. series: { type: Array, default: function() { return [] } },
  26. width: { type: Number, default: 690 },
  27. height: { type: Number, default: 420 },
  28. /** Y 轴自动范围下界(默认 min-5%) */
  29. yMin: { type: Number, default: 0 },
  30. /** 是否绘制数据点数值标注 */
  31. showValues: { type: Boolean, default: true }
  32. },
  33. data: function() {
  34. return {
  35. canvasReady: false, canvasWidth: 0, canvasHeight: 0, ctx: null, dpr: 1,
  36. _canvasNode: null, _canvasLeft: 0, _canvasTop: 0,
  37. _destroyed: false, _initTimer: null,
  38. canvasId: 'trendCanvas_' + (this._uid || 0)
  39. }
  40. },
  41. computed: {
  42. containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
  43. canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
  44. },
  45. watch: {
  46. series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
  47. },
  48. mounted: function() {
  49. var self = this
  50. var retryCount = 0
  51. var maxRetry = 5
  52. function tryInit() {
  53. if (self._destroyed) return
  54. self.initCanvas(function(success) {
  55. if (self._destroyed) return
  56. if (!success && retryCount < maxRetry) {
  57. retryCount++
  58. self._initTimer = setTimeout(tryInit, 60)
  59. } else if (!success) {
  60. console.log('[TrendChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
  61. }
  62. })
  63. }
  64. self._initTimer = setTimeout(tryInit, 100)
  65. },
  66. beforeDestroy: function() {
  67. this._destroyed = true
  68. if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null }
  69. },
  70. methods: {
  71. getItemKey: function(s, i) { return (s && s.name) || i },
  72. initCanvas: function(cb) {
  73. var self = this
  74. uni.createSelectorQuery().in(this)
  75. .select('#' + this.canvasId)
  76. .fields({ node: true, size: true, rect: true })
  77. .exec(function(res) {
  78. try {
  79. if (self._destroyed) return
  80. if (!res || !res[0] || !res[0].node) { if (cb) cb(false); return }
  81. var info = res[0]
  82. var canvas = info.node
  83. var width = info.width
  84. var height = info.height
  85. if (!width || !height) {
  86. var winWidth = 375
  87. try { winWidth = uni.getWindowInfo().windowWidth || 375 } catch (e) {}
  88. width = Math.round((self.width || 690) * winWidth / 750)
  89. height = Math.round((self.height || 420) * winWidth / 750)
  90. }
  91. var pixelRatio = 2
  92. try { pixelRatio = uni.getWindowInfo().pixelRatio || 2 } catch (e) {}
  93. canvas.width = width * pixelRatio
  94. canvas.height = height * pixelRatio
  95. var ctx = canvas.getContext('2d')
  96. if (!ctx) { if (cb) cb(false); return }
  97. ctx.scale(pixelRatio, pixelRatio)
  98. self.ctx = ctx
  99. self._canvasNode = canvas
  100. self.canvasWidth = width
  101. self.canvasHeight = height
  102. self.dpr = pixelRatio
  103. self._canvasLeft = info.left || 0
  104. self._canvasTop = info.top || 0
  105. self.canvasReady = true
  106. self._doRender()
  107. if (cb) cb(true)
  108. } catch (e) {
  109. console.log('[TrendChart] initCanvas 回调异常', e)
  110. if (cb) cb(false)
  111. }
  112. })
  113. },
  114. _safeSeries: function(arr) {
  115. if (!Array.isArray(arr)) return []
  116. return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
  117. },
  118. /** 计算 X 轴时间范围与 Y 轴数值范围 */
  119. _computeRanges: function(seriesList) {
  120. var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
  121. seriesList.forEach(function(s) {
  122. s.points.forEach(function(p) {
  123. var v = parseFloat(p.value)
  124. if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
  125. if (!firstDate || p.reportDate < firstDate) firstDate = p.reportDate
  126. if (!lastDate || p.reportDate > lastDate) lastDate = p.reportDate
  127. })
  128. })
  129. if (min === Infinity) { min = 0; max = 1 }
  130. if (max === min) { max = min + 1 }
  131. var pad = (max - min) * 0.08
  132. return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
  133. },
  134. _doRender: function() {
  135. if (!this.ctx) return
  136. var self = this
  137. var ctx = this.ctx
  138. var w = this.canvasWidth, h = this.canvasHeight
  139. var padL = 34, padR = 14, padT = 16, padB = 26
  140. var plotW = w - padL - padR, plotH = h - padT - padB
  141. var seriesList = this._safeSeries(this.series)
  142. if (seriesList.length === 0) {
  143. ctx.clearRect(0, 0, w, h)
  144. ctx.fillStyle = BrandColors.appTextSubtle
  145. ctx.font = '12px sans-serif'
  146. ctx.textAlign = 'center'
  147. ctx.fillText('暂无趋势数据', w / 2, h / 2)
  148. return
  149. }
  150. var ranges = this._computeRanges(seriesList)
  151. var yMin = ranges.yMin, yMax = ranges.yMax
  152. function yOf(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
  153. // 日期横轴:按所有序列全部点排序后均分
  154. var allDates = []
  155. seriesList.forEach(function(s) {
  156. s.points.forEach(function(p) {
  157. if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
  158. })
  159. })
  160. allDates.sort()
  161. function xOf(dateStr) {
  162. if (allDates.length <= 1) return padL + plotW / 2
  163. var idx = allDates.indexOf(dateStr)
  164. if (idx < 0) return padL
  165. return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
  166. }
  167. ctx.clearRect(0, 0, w, h)
  168. // 网格(4 条横线 + 2 条纵线)
  169. ctx.strokeStyle = BrandColors.slateGhost
  170. ctx.lineWidth = 1
  171. for (var gi = 0; gi <= 3; gi++) {
  172. var gy = padT + plotH * (gi / 3)
  173. ctx.beginPath()
  174. ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy)
  175. ctx.stroke()
  176. }
  177. // Y 轴刻度文字
  178. ctx.fillStyle = BrandColors.appTextSubtle
  179. ctx.font = '10px sans-serif'
  180. ctx.textAlign = 'right'
  181. for (var ti = 0; ti <= 3; ti++) {
  182. var tv = yMin + (yMax - yMin) * (1 - ti / 3)
  183. var ty = padT + plotH * (ti / 3)
  184. ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
  185. }
  186. // 折线 + 数据点 + 数值
  187. var palette = [BrandColors.primary, BrandColors.infoSky, BrandColors.success, BrandColors.violet, BrandColors.danger, BrandColors.wealth]
  188. seriesList.forEach(function(s, si) {
  189. var color = s.color || palette[si % palette.length]
  190. ctx.strokeStyle = color
  191. ctx.lineWidth = 2
  192. ctx.beginPath()
  193. var started = false
  194. s.points.forEach(function(p) {
  195. var v = parseFloat(p.value)
  196. if (isNaN(v)) return
  197. var x = xOf(p.reportDate), y = yOf(v)
  198. if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
  199. })
  200. ctx.stroke()
  201. // 数据点 + 值
  202. s.points.forEach(function(p) {
  203. var v = parseFloat(p.value)
  204. if (isNaN(v)) return
  205. var x = xOf(p.reportDate), y = yOf(v)
  206. ctx.fillStyle = color
  207. ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
  208. if (self.showValues) {
  209. ctx.fillStyle = BrandColors.slate600
  210. ctx.font = '10px sans-serif'
  211. ctx.textAlign = 'center'
  212. ctx.fillText(self._fmtValue(p), x, y - 8)
  213. }
  214. })
  215. // 序列名(最右端上方)
  216. ctx.fillStyle = color
  217. ctx.font = '11px sans-serif'
  218. ctx.textAlign = 'right'
  219. var last = s.points[s.points.length - 1]
  220. if (last && !isNaN(parseFloat(last.value))) {
  221. ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
  222. }
  223. })
  224. // X 轴日期标签(首/中/末)
  225. ctx.fillStyle = BrandColors.appTextSubtle
  226. ctx.font = '10px sans-serif'
  227. ctx.textAlign = 'center'
  228. if (allDates.length > 0) {
  229. ctx.fillText(String(allDates[0]).slice(5), padL, h - 8)
  230. if (allDates.length > 1) {
  231. var midIdx = Math.floor((allDates.length - 1) / 2)
  232. ctx.fillText(String(allDates[midIdx]).slice(5), padL + plotW * (midIdx / (allDates.length - 1)), h - 8)
  233. ctx.fillText(String(allDates[allDates.length - 1]).slice(5), w - padR, h - 8)
  234. }
  235. }
  236. },
  237. _fmtNum: function(v) {
  238. if (v >= 100) return Math.round(v) + ''
  239. if (v >= 10) return v.toFixed(1)
  240. return v.toFixed(2)
  241. },
  242. _fmtValue: function(p) {
  243. var v = parseFloat(p.value)
  244. if (isNaN(v)) return '--'
  245. return this._fmtNum(v) + (p.unit ? p.unit : '')
  246. },
  247. onCanvasTap: function(e) {
  248. // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
  249. this.$emit('chartTap')
  250. }
  251. }
  252. }
  253. </script>
  254. <style scoped lang="scss">
  255. .trend-chart-wrap {
  256. display: flex;
  257. flex-direction: column;
  258. }
  259. .trend-container {
  260. position: relative;
  261. }
  262. .trend-canvas {
  263. display: block;
  264. }
  265. .trend-tap-layer {
  266. position: absolute;
  267. left: 0;
  268. top: 0;
  269. width: 100%;
  270. height: 100%;
  271. }
  272. .trend-legend {
  273. display: flex;
  274. flex-direction: row;
  275. flex-wrap: wrap;
  276. justify-content: center;
  277. padding-top: 12rpx;
  278. }
  279. .legend-item {
  280. display: flex;
  281. flex-direction: row;
  282. align-items: center;
  283. margin: 0 16rpx 8rpx;
  284. }
  285. .legend-line {
  286. width: 32rpx;
  287. border-top: 4rpx solid $uni-info;
  288. margin-right: 8rpx;
  289. }
  290. .legend-text {
  291. font-size: 22rpx;
  292. color: $facade-text-slate-deep;
  293. }
  294. </style>