TrendChart.vue 10 KB

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