| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- <template>
- <view class="trend-chart-wrap">
- <view class="trend-container" :style="containerStyle">
- <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
- <view class="trend-tap-layer" @click="onCanvasTap"></view>
- </view>
- <view class="trend-legend" v-if="series.length > 1">
- <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
- <view class="legend-line" :style="'border-color:' + s.color"></view>
- <text class="legend-text">{{ s.name }}</text>
- </view>
- </view>
- </view>
- </template>
- <script>
- /**
- * TrendChart - 多序列折线图 Canvas 组件
- * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
- * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
- */
- export default {
- props: {
- /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
- series: { type: Array, default: function() { return [] } },
- width: { type: Number, default: 690 },
- height: { type: Number, default: 420 },
- /** Y 轴自动范围下界(默认 min-5%) */
- yMin: { type: Number, default: 0 },
- /** 是否绘制数据点数值标注 */
- showValues: { type: Boolean, default: true }
- },
- data: function() {
- return {
- canvasReady: false, canvasWidth: 0, canvasHeight: 0, ctx: null, dpr: 1,
- _canvasNode: null, _canvasLeft: 0, _canvasTop: 0,
- _destroyed: false, _initTimer: null,
- canvasId: 'trendCanvas_' + (this._uid || 0)
- }
- },
- computed: {
- containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
- canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
- },
- watch: {
- series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
- },
- mounted: function() {
- var self = this
- var retryCount = 0
- var maxRetry = 5
- function tryInit() {
- if (self._destroyed) return
- self.initCanvas(function(success) {
- if (self._destroyed) return
- if (!success && retryCount < maxRetry) {
- retryCount++
- self._initTimer = setTimeout(tryInit, 60)
- } else if (!success) {
- console.log('[TrendChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
- }
- })
- }
- self._initTimer = setTimeout(tryInit, 100)
- },
- beforeDestroy: function() {
- this._destroyed = true
- if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null }
- },
- methods: {
- getItemKey: function(s, i) { return (s && s.name) || i },
- initCanvas: function(cb) {
- var self = this
- uni.createSelectorQuery().in(this)
- .select('#' + this.canvasId)
- .fields({ node: true, size: true, rect: true })
- .exec(function(res) {
- try {
- if (self._destroyed) return
- if (!res || !res[0] || !res[0].node) { if (cb) cb(false); return }
- var info = res[0]
- var canvas = info.node
- var width = info.width
- var height = info.height
- if (!width || !height) {
- var winWidth = 375
- try { winWidth = uni.getWindowInfo().windowWidth || 375 } catch (e) {}
- width = Math.round((self.width || 690) * winWidth / 750)
- height = Math.round((self.height || 420) * winWidth / 750)
- }
- var pixelRatio = 2
- try { pixelRatio = uni.getWindowInfo().pixelRatio || 2 } catch (e) {}
- canvas.width = width * pixelRatio
- canvas.height = height * pixelRatio
- var ctx = canvas.getContext('2d')
- if (!ctx) { if (cb) cb(false); return }
- ctx.scale(pixelRatio, pixelRatio)
- self.ctx = ctx
- self._canvasNode = canvas
- self.canvasWidth = width
- self.canvasHeight = height
- self.dpr = pixelRatio
- self._canvasLeft = info.left || 0
- self._canvasTop = info.top || 0
- self.canvasReady = true
- self._doRender()
- if (cb) cb(true)
- } catch (e) {
- console.log('[TrendChart] initCanvas 回调异常', e)
- if (cb) cb(false)
- }
- })
- },
- _safeSeries: function(arr) {
- if (!Array.isArray(arr)) return []
- return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
- },
- /** 计算 X 轴时间范围与 Y 轴数值范围 */
- _computeRanges: function(seriesList) {
- var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
- seriesList.forEach(function(s) {
- s.points.forEach(function(p) {
- var v = parseFloat(p.value)
- if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
- if (!firstDate || p.reportDate < firstDate) firstDate = p.reportDate
- if (!lastDate || p.reportDate > lastDate) lastDate = p.reportDate
- })
- })
- if (min === Infinity) { min = 0; max = 1 }
- if (max === min) { max = min + 1 }
- var pad = (max - min) * 0.08
- return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
- },
- _doRender: function() {
- if (!this.ctx) return
- var self = this
- var ctx = this.ctx
- var w = this.canvasWidth, h = this.canvasHeight
- var padL = 34, padR = 14, padT = 16, padB = 26
- var plotW = w - padL - padR, plotH = h - padT - padB
- var seriesList = this._safeSeries(this.series)
- if (seriesList.length === 0) {
- ctx.clearRect(0, 0, w, h)
- ctx.fillStyle = '#94A3B8'
- ctx.font = '12px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText('暂无趋势数据', w / 2, h / 2)
- return
- }
- var ranges = this._computeRanges(seriesList)
- var yMin = ranges.yMin, yMax = ranges.yMax
- function yOf(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
- // 日期横轴:按所有序列全部点排序后均分
- var allDates = []
- seriesList.forEach(function(s) {
- s.points.forEach(function(p) {
- if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
- })
- })
- allDates.sort()
- function xOf(dateStr) {
- if (allDates.length <= 1) return padL + plotW / 2
- var idx = allDates.indexOf(dateStr)
- if (idx < 0) return padL
- return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
- }
- ctx.clearRect(0, 0, w, h)
- // 网格(4 条横线 + 2 条纵线)
- ctx.strokeStyle = '#EEF2F7'
- ctx.lineWidth = 1
- for (var gi = 0; gi <= 3; gi++) {
- var gy = padT + plotH * (gi / 3)
- ctx.beginPath()
- ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy)
- ctx.stroke()
- }
- // Y 轴刻度文字
- ctx.fillStyle = '#94A3B8'
- ctx.font = '10px sans-serif'
- ctx.textAlign = 'right'
- for (var ti = 0; ti <= 3; ti++) {
- var tv = yMin + (yMax - yMin) * (1 - ti / 3)
- var ty = padT + plotH * (ti / 3)
- ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
- }
- // 折线 + 数据点 + 数值
- var palette = ['#F97316', '#0EA5E9', '#10B981', '#8B5CF6', '#EF4444', '#F59E0B']
- seriesList.forEach(function(s, si) {
- var color = s.color || palette[si % palette.length]
- ctx.strokeStyle = color
- ctx.lineWidth = 2
- ctx.beginPath()
- var started = false
- s.points.forEach(function(p) {
- var v = parseFloat(p.value)
- if (isNaN(v)) return
- var x = xOf(p.reportDate), y = yOf(v)
- if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
- })
- ctx.stroke()
- // 数据点 + 值
- s.points.forEach(function(p) {
- var v = parseFloat(p.value)
- if (isNaN(v)) return
- var x = xOf(p.reportDate), y = yOf(v)
- ctx.fillStyle = color
- ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
- if (self.showValues) {
- ctx.fillStyle = '#475569'
- ctx.font = '10px sans-serif'
- ctx.textAlign = 'center'
- ctx.fillText(self._fmtValue(p), x, y - 8)
- }
- })
- // 序列名(最右端上方)
- ctx.fillStyle = color
- ctx.font = '11px sans-serif'
- ctx.textAlign = 'right'
- var last = s.points[s.points.length - 1]
- if (last && !isNaN(parseFloat(last.value))) {
- ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
- }
- })
- // X 轴日期标签(首/中/末)
- ctx.fillStyle = '#94A3B8'
- ctx.font = '10px sans-serif'
- ctx.textAlign = 'center'
- if (allDates.length > 0) {
- ctx.fillText(String(allDates[0]).slice(5), padL, h - 8)
- if (allDates.length > 1) {
- var midIdx = Math.floor((allDates.length - 1) / 2)
- ctx.fillText(String(allDates[midIdx]).slice(5), padL + plotW * (midIdx / (allDates.length - 1)), h - 8)
- ctx.fillText(String(allDates[allDates.length - 1]).slice(5), w - padR, h - 8)
- }
- }
- },
- _fmtNum: function(v) {
- if (v >= 100) return Math.round(v) + ''
- if (v >= 10) return v.toFixed(1)
- return v.toFixed(2)
- },
- _fmtValue: function(p) {
- var v = parseFloat(p.value)
- if (isNaN(v)) return '--'
- return this._fmtNum(v) + (p.unit ? p.unit : '')
- },
- onCanvasTap: function(e) {
- // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
- this.$emit('chartTap')
- }
- }
- }
- </script>
- <style scoped>
- .trend-chart-wrap {
- display: flex;
- flex-direction: column;
- }
- .trend-container {
- position: relative;
- }
- .trend-canvas {
- display: block;
- }
- .trend-tap-layer {
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- }
- .trend-legend {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- justify-content: center;
- padding-top: 12rpx;
- }
- .legend-item {
- display: flex;
- flex-direction: row;
- align-items: center;
- margin: 0 16rpx 8rpx;
- }
- .legend-line {
- width: 32rpx;
- border-top: 4rpx solid #0EA5E9;
- margin-right: 8rpx;
- }
- .legend-text {
- font-size: 22rpx;
- color: #475569;
- }
- </style>
|