|
|
@@ -0,0 +1,302 @@
|
|
|
+<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>
|