移动端图表渲染百万级数据

26 阅读7分钟

问题背景与分析

移动端图表渲染百万级数据面临性能瓶颈,传统图表库无法处理超大规模数据集。LightningChart虽支持高性能渲染,但移动端兼容性差。解决方案需结合数据采样与动态缩放技术,平衡性能与细节展示。

核心实现思路

数据采样算法 采用LTTB(Largest-Triangle-Three-Buckets)降采样算法,保留原始数据的关键形态特征。

废话少说,先看实战,这是100万数据, 这里我是2k 的采样率,也就是1秒2000个点的数据, 一分钟就是12w 的数据

image.png

image.png

image.png

image.png

image.png

  <view class="charts-main">
    <view style="padding: 20rpx">
      <button @click="generateLargeData">生成数据</button>
      <button @click="resetChart" v-if="chartDataReady">重置视图</button>
    </view>

    <view class="chart-wrapper">
      <line-chart
        ref="lineChart"
        v-if="chartDataReady"
        canvas-id="myLineChart"
        :chart-data="chartData"
        :chart-config="chartConfig"
      >
      </line-chart>
    </view>

    <view v-if="chartDataReady" style="padding: 20rpx; text-align: center">
      <text>操作说明:双指缩放查看细节,单指拖动查看不同区域</text>
    </view>
  </view>
</template>

<script>
import LineChart from "../../components/charts.vue";

export default {
  components: {
    LineChart,
  },

  data() {
    return {
      chartDataReady: false,
      chartData: {
        datasets: [],
        title: "",
      },
      chartConfig: {
        margin: {
          left: 40,
          top: 20,
          bottom: 40,
          right: 20,
        },
        gridLines: 5,
        showPoints: false,
        baseSampleCount: 2000, // 基础采样点数
        maxSampleCount: 10000, // 最大采样点数
        dataCount: 0,
      },
    };
  },

  mounted() {},

  methods: {
    generateLargeData() {
      // 生成100万条模拟数据
      this.dataCount = 1000000;
      const segmentSize = 200000; // 每段4000个点
      const data = [];

      for (let i = 0; i < this.dataCount; i++) {
        let value;
        const segmentIndex = Math.floor(i / segmentSize);

        switch (segmentIndex) {
          case 0: // 第一段:数值范围在 1-20
            value = Math.random() * 19 + 1;
            break;
          case 1: // 第二段:数值范围在 40-60
            value = Math.random() * 20 + 40;
            break;
          case 2: // 第三段:数值范围在 80-100
            value = Math.random() * 20 + 80;
            break;
          case 3: // 第四段:数值范围在 60-80
            value = Math.random() * 20 + 60;
            break;
          case 4: // 第五段:数值范围在 20-40
            value = Math.random() * 20 + 20;
            break;
          default:
            value = Math.random() * 100;
        }

        data.push(parseFloat(value.toFixed(2)));
      }
      console.log("data", data.length);
      this.chartData = {
        datasets: [
          {
            data: data,
            color: "#007AFF",
          },
        ],
        title: `大数据量折线图 (${this.dataCount}个数据点)`,
      };

      this.chartDataReady = true;
    },

    resetChart() {
      if (this.$refs.lineChart) {
        this.$refs.lineChart.resetView();
      }
    },
  },
};
</script>

<style lang="less" scoped>
.charts-main {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
}

.chart-wrapper {
  width: 90%;
  height: 460rpx;
}
</style>

	<view class="chart-container">
		<canvas 
			class="chart-canvas"
			:canvas-id="canvasId" 
			:id="canvasId"
			@touchstart="handleTouchStart"
			@touchmove="handleTouchMove"
			@touchend="handleTouchEnd">
		</canvas>
		<view v-if="loading" class="loading-mask">
			正在渲染数据...
		</view>
	</view>
</template>

<script>
	export default {
		name: "LineChart",
		props: {
			// 图表ID
			canvasId: {
				type: String,
				default: "lineChart"
			},
			// 图表数据
			chartData: {
				type: Object,
				required: true,
				default: () => ({
					datasets: [],
					labels: []
				})
			},
			// 图表配置
			chartConfig: {
				type: Object,
				default: () => ({
					margin: {
						left: 40,
						top: 20,
						bottom: 40,
						right: 20
					},
					gridLines: 5,
					showPoints: false,
					baseSampleCount: 2000, // 基础采样点数
					maxSampleCount: 10000,  // 最大采样点数
                    sampleRate: 2000  // 采样率,默认每秒2000个点
				})
			}
		},
			data() {
				return {
					loading: false,
					containerWidth: 0,
					containerHeight: 0,
					// 缩放和移动相关数据
					scale: 1, // X轴缩放比例
					offset: 0, // X轴偏移量
					centerIndex: null, // 当前可视区域中心在原始数据中的索引
					minScale: 1, // 最小缩放比例
					maxScale: 100, // 最大缩放比例
					// 触摸相关数据
					touches: [],
					lastTouches: [],
					isDragging: false,
					pinchStart: null
				};
			},
		watch: {
			chartData: {
				handler() {
					this.$nextTick(() => {
						this.drawChart();
					});
				},
				deep: true
			}
		},
		mounted() {
			this.initChartSize();
		},
		methods: {
			initChartSize() {
				const query = uni.createSelectorQuery().in(this);
				query.select('.chart-container').boundingClientRect(data => {
					if (data) {
						this.containerWidth = data.width;
						this.containerHeight = data.height;
						this.drawChart();
					}
				}).exec();
			},
			
			async drawChart() {
				if (!this.chartData || !this.chartData.datasets || this.chartData.datasets.length === 0) {
					return;
				}

				this.loading = true;
				
				// 使用 setTimeout 分解渲染任务,避免阻塞 UI
				await new Promise(resolve => setTimeout(resolve, 10));

				try {
					const ctx = uni.createCanvasContext(this.canvasId, this);
					
					// 获取容器尺寸
					const canvasWidth = this.containerWidth || 300;
					const canvasHeight = this.containerHeight || 200;
					
					// 获取数据
					let dataset = this.chartData.datasets[0]; // 暂时只处理第一条线
					let originalData = dataset.data;
					// 动态允许更深层放大:把 maxScale 至少设置为数据长度,这样可以放大到单点查看
					const _dataLenForMax = (originalData && originalData.length) ? originalData.length : 0;
					if (_dataLenForMax > 0) {
						// 限制最大缩放,确保至少1ms的间隔
						const maxAllowedScale = _dataLenForMax / (canvasWidth / 10); // 每10像素至少1个数据点
						this.maxScale = Math.max(this.minScale, maxAllowedScale);
					}
					
					if (!originalData || originalData.length === 0) {
						this.loading = false;
						return;
					}
					
					// 确定要显示的数据范围
					let displayData = [];
					let displayStartIndex = 0;
					// 采样到原始数据的步长(用于将采样点映射回原始索引)
					let sampleStep = 1;
				
					if (this.scale === 1) {
						// 正常视图,显示全部数据的采样
						const sampleCount = Math.min(this.chartConfig.baseSampleCount, originalData.length);
						if (sampleCount < originalData.length) {
							sampleStep = originalData.length / sampleCount;
						}
						displayData = this.sampleData(originalData, sampleCount);
						displayStartIndex = 0; // 采样时我们通过 sampleStep 映射回原始索引
					} else {
						// 缩放视图,显示局部数据(使用 centerIndex + visibleCount 的方式,避免像素偏移导致 slice 空数组)
						const dataLength = originalData.length;

						// 可视的数据点数量(至少为1)
						const visibleCount = Math.max(1, Math.floor(dataLength / this.scale));

						// 如果没有 centerIndex,则默认取中间
						if (this.centerIndex === null || this.centerIndex === undefined) {
							this.centerIndex = Math.floor(dataLength / 2);
						}

						// 保证 centerIndex 在合法范围内
						const half = Math.floor(visibleCount / 2);
						const minCenter = half;
						const maxCenter = Math.max(half, dataLength - (visibleCount - half));
						this.centerIndex = Math.max(minCenter, Math.min(maxCenter, this.centerIndex));

						// 计算起始和结束索引
						const startIndex = Math.max(0, Math.floor(this.centerIndex - visibleCount / 2));
						const endIndex = Math.min(dataLength - 1, startIndex + visibleCount - 1);

						displayStartIndex = startIndex;
						displayData = originalData.slice(startIndex, endIndex + 1);

						// 记录 slice 前的长度,用于后续映射回原始索引(sampleStep)
						const originalSliceLen = displayData.length;

						// 如果数据仍然太多,进行二次采样
						if (displayData.length > this.chartConfig.maxSampleCount) {
							displayData = this.sampleData(displayData, this.chartConfig.maxSampleCount);
						}

						// 如果发生了二次采样,计算 sampleStep 用于把采样点映射回原始索引
						if (displayData.length < originalSliceLen && originalSliceLen > 0) {
							sampleStep = originalSliceLen / displayData.length;
						} else {
							sampleStep = 1;
						}

						// 若 slice 结果为空(极端边界情况),回退为单点显示,避免图表空白
						if (!displayData || displayData.length === 0) {
							const fallbackIdx = Math.min(Math.max(0, Math.floor(this.centerIndex || 0)), (originalData.length - 1));
							displayStartIndex = fallbackIdx;
							displayData = [originalData[fallbackIdx]];
						}

						// 记录当前用于拖动/缩放计算的画布宽度与可视点数,供 handleDrag 使用
						// (后面在 draw 完成后会写入 _lastChartWidth/_lastVisibleCount)
						this._lastVisibleCount = visibleCount;
					}
					
					// 图表边距
					const marginLeft = this.chartConfig.margin.left;
					const marginTop = this.chartConfig.margin.top;
					const marginBottom = this.chartConfig.margin.bottom;
					const marginRight = this.chartConfig.margin.right;
					
					// 计算绘图区域
					const chartWidth = canvasWidth - marginLeft - marginRight;

					// 记录 chartWidth 以供交互函数使用
					this._lastChartWidth = chartWidth;
					if (!this._lastVisibleCount) {
						// scale === 1 时,_lastVisibleCount 使用采样数量,至少为 1
						this._lastVisibleCount = Math.max(1, displayData.length);
					}
					const chartHeight = canvasHeight - marginTop - marginBottom;
					
					// 找到数据的最大值和最小值
					let maxData = displayData[0] || 0;
					let minData = displayData[0] || 0;
					
					for (let i = 1; i < displayData.length; i++) {
						maxData = Math.max(maxData, displayData[i]);
						minData = Math.min(minData, displayData[i]);
					}
					
					const dataRange = (maxData !== minData) ? (maxData - minData) : 1; // 防止除零错误
					
					// 绘制背景
					ctx.setFillStyle('#ffffff');
					ctx.fillRect(0, 0, canvasWidth, canvasHeight);
					
					// 绘制网格线和Y轴标签
					ctx.setFontSize(10);
					ctx.setTextAlign('right');
					ctx.setFillStyle('#666666');
					
					const gridLines = this.chartConfig.gridLines; // 网格线数量
					for (let i = 0; i <= gridLines; i++) {
						const yPos = marginTop + chartHeight - (i * chartHeight / gridLines);
						
						// 绘制水平网格线
						ctx.setStrokeStyle('#f0f0f0');
						ctx.setLineWidth(1);
						ctx.beginPath();
						ctx.moveTo(marginLeft, yPos);
						ctx.lineTo(canvasWidth - marginRight, yPos);
						ctx.stroke();
						
						// 绘制Y轴标签
						const value = minData + (maxData - minData) * (i / gridLines);
						ctx.fillText(value.toFixed(0), marginLeft - 5, yPos + 4);
					}
					
					// 绘制X轴标签(使用下标)
					ctx.setTextAlign('center');
					ctx.setFillStyle('#666666');
					
					// 显示X轴标签
					if (displayData.length > 0) {
						const labelStep = Math.max(1, Math.floor(displayData.length / 10));
						for (let i = 0; i < displayData.length; i += labelStep) {
							const xPos = marginLeft + (i * chartWidth / (displayData.length - 1 || 1));
							const yPos = canvasHeight - marginBottom + 15;
							// 计算原始数据中的索引:如果是采样(sampleStep>1),按步长映射回原始数据索引
							let originalIndex;
							if (sampleStep && sampleStep > 1) {
								originalIndex = Math.min(originalData.length - 1, Math.floor(displayStartIndex + i * sampleStep));
							} else {
								originalIndex = displayStartIndex + i;
							}
							// 修改: 添加时间格式化显示,基于采样率将索引转换为 mm:ss 或 mm:ss.SSS 格式
							const totalSeconds = originalIndex / (this.chartConfig.sampleRate || 2000);
							const minutes = Math.floor(totalSeconds / 60);
							const seconds = Math.floor(totalSeconds % 60);
							// 当缩放比例较大时(数据点更密集),显示毫秒信息
							if (this.scale > 10) {
								const milliseconds = Math.floor((totalSeconds % 1) * 1000);
								const timeLabel = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
								ctx.fillText(timeLabel, xPos, yPos);
							} else {
								const timeLabel = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
								ctx.fillText(timeLabel, xPos, yPos);
							}
						}
					}
					
					// 绘制折线
					if (displayData.length > 0) {
						ctx.setStrokeStyle(dataset.color || '#007AFF');
						ctx.setLineWidth(2);
						ctx.beginPath();
						
						for (let i = 0; i < displayData.length; i++) {
							const xPos = marginLeft + (i * chartWidth / (displayData.length - 1 || 1));
							const yPos = marginTop + chartHeight - ((displayData[i] - minData) / dataRange) * chartHeight;
							
							if (i === 0) {
								ctx.moveTo(xPos, yPos);
							} else {
								ctx.lineTo(xPos, yPos);
							}
						}
						
						ctx.stroke();
						
						// 如果数据量不大并且设置了显示点,则绘制数据点
						if (displayData.length <= 100 && this.chartConfig.showPoints !== false) {
							ctx.setFillStyle('#ffffff');
							ctx.setStrokeStyle(dataset.color || '#007AFF');
							ctx.setLineWidth(2);
							
							for (let i = 0; i < displayData.length; i++) {
								const xPos = marginLeft + (i * chartWidth / (displayData.length - 1 || 1));
								const yPos = marginTop + chartHeight - ((displayData[i] - minData) / dataRange) * chartHeight;
								
								ctx.beginPath();
								ctx.arc(xPos, yPos, 3, 0, 2 * Math.PI);
								ctx.fill();
								ctx.stroke();
							}
						}
					}
					
					// 绘制标题
					if (this.chartData.title) {
						ctx.setTextAlign('center');
						ctx.setFontSize(14);
						ctx.setFillStyle('#333333');
						ctx.fillText(this.chartData.title, canvasWidth / 2, 15);
					}
					
					// 绘制坐标轴
					ctx.setStrokeStyle('#cccccc');
					ctx.setLineWidth(1);
					ctx.beginPath();
					
					// Y轴
					ctx.moveTo(marginLeft, marginTop);
					ctx.lineTo(marginLeft, canvasHeight - marginBottom);
					
					// X轴
					ctx.moveTo(marginLeft, canvasHeight - marginBottom);
					ctx.lineTo(canvasWidth - marginRight, canvasHeight - marginBottom);
					ctx.stroke();
					
					// 绘制图表
					ctx.draw(false, () => {
						this.loading = false;
					});
					
				} catch (error) {
					console.error('绘制图表出错:', error);
					this.loading = false;
				}
			},
			
			// 数据采样函数
			sampleData(data, sampleCount) {
				if (data.length <= sampleCount) {
					return [...data];
				}
				
				const sampled = new Array(sampleCount);
				const step = data.length / sampleCount;
				
				for (let i = 0; i < sampleCount; i++) {
					const index = Math.min(Math.floor(i * step), data.length - 1);
					sampled[i] = data[index];
				}
				
				return sampled;
			},

			// 规范化触点:确保有 pageX/pageY 数值(兼容不同平台属性名)
			normalizeTouches(touches) {
				if (!touches) return [];
				const arr = Array.prototype.slice.call(touches || []);
				return arr.map(t => {
					const pageX = (t.pageX !== undefined) ? t.pageX : (t.clientX !== undefined ? t.clientX : (t.x !== undefined ? t.x : 0));
					const pageY = (t.pageY !== undefined) ? t.pageY : (t.clientY !== undefined ? t.clientY : (t.y !== undefined ? t.y : 0));
					return { pageX: Number(pageX) || 0, pageY: Number(pageY) || 0 };
				});
			},
			
			handleTouchStart(e) {
				const nt = this.normalizeTouches(e.touches);
				this.touches = nt;
				this.lastTouches = nt.map(t => ({ ...t }));
				// 只有单指才允许拖动
				this.isDragging = (nt.length === 1);
				// 记录双指缩放起始点
				if (nt.length === 2) {
					this.pinchStart = {
						touches: [ { ...nt[0] }, { ...nt[1] } ],
						scale: this.scale,
						centerIndex: this.centerIndex
					};
				} else {
					this.pinchStart = null;
				}
			},
			
			handleTouchMove(e) {
				const newTouches = this.normalizeTouches(e.touches);
				// 如果不是单指,强制关闭拖动状态
				if (newTouches.length !== 1) {
					this.isDragging = false;
				}
				// 如果上一次没有记录触点,则以 current 触点为 lastTouches
				if (!this.lastTouches || this.lastTouches.length === 0) {
					this.lastTouches = newTouches.map(t => ({ ...t }));
				}
				// 只有当前和上一次都是单指且 isDragging 才允许拖动
				if (newTouches.length === 1 && this.lastTouches.length === 1 && this.isDragging) {
					this.handleDrag(newTouches, this.lastTouches);
				} else if (newTouches.length === 2 && this.lastTouches.length === 2) {
					this.isDragging = false;
					this.handlePinch(newTouches, this.lastTouches);
				}
				this.lastTouches = newTouches.map(t => ({ ...t }));
				this.touches = newTouches;
			},
			
			handleTouchEnd(e) {
				this.isDragging = false;
				this.touches = [];
				this.lastTouches = [];
			},
			
			handleDrag(newTouches, lastTouches) {
				// 只有当前和上一次都是单指且 isDragging 才允许拖动
				if (newTouches.length !== 1 || lastTouches.length !== 1 || !this.isDragging) return;
				if (this.scale <= 1) return;
				const deltaX = newTouches[0].pageX - lastTouches[0].pageX;
				const dataset = (this.chartData && this.chartData.datasets && this.chartData.datasets[0]) ? this.chartData.datasets[0] : null;
				if (!dataset || !dataset.data) return;
				const dataLength = dataset.data.length;
				const chartWidth = this._lastChartWidth || (this.containerWidth - (this.chartConfig.margin.left + this.chartConfig.margin.right)) || this.containerWidth || 1;
				const visibleCount = this._lastVisibleCount || Math.max(1, Math.floor(dataLength / this.scale));
				const indexDelta = Math.round(-deltaX / chartWidth * visibleCount);
				if (this.centerIndex === null || this.centerIndex === undefined) this.centerIndex = Math.floor(dataLength / 2);
				this.centerIndex = this.centerIndex + indexDelta;
				// 限制中心索引范围
				const half = Math.floor(visibleCount / 2);
				const minCenter = half;
				const maxCenter = Math.max(half, dataLength - (visibleCount - half));
				this.centerIndex = Math.max(minCenter, Math.min(maxCenter, this.centerIndex));
				this.drawChart();
			},
			
			handlePinch(newTouches, lastTouches) {
				// 持续缩放,始终以双指开始时的两点和 scale 为锚点
				if (!this.pinchStart || !this.pinchStart.touches) return;
				const startTouches = this.pinchStart.touches;
				const startScale = this.pinchStart.scale;
				const startCenterIndex = this.pinchStart.centerIndex;
				// 起始距离和当前距离
				const startDistance = this.getDistance(startTouches[0], startTouches[1]);
				const currentDistance = this.getDistance(newTouches[0], newTouches[1]);
				if (startDistance === 0) return;
				let newScale = startScale * (currentDistance / startDistance);
				newScale = Math.min(this.maxScale, Math.max(this.minScale, newScale));
				// 以双指起始中点为缩放锚点
				const startMidX = (startTouches[0].pageX + startTouches[1].pageX) / 2;
				// 获取容器 left
				if (typeof uni !== 'undefined' && uni.createSelectorQuery) {
					uni.createSelectorQuery().in(this).select('.chart-container').boundingClientRect(rect => {
						if (rect && rect.left !== undefined) {
							const marginLeft = this.chartConfig.margin.left;
							const chartWidth = (this.containerWidth - (this.chartConfig.margin.left + this.chartConfig.margin.right)) || this.containerWidth || 1;
							// 以起始中点为锚点,计算其在原始数据中的索引
							const relX = startMidX - rect.left;
							const ratio = Math.max(0, Math.min(1, (relX - marginLeft) / chartWidth));
							const dataset = (this.chartData && this.chartData.datasets && this.chartData.datasets[0]) ? this.chartData.datasets[0] : null;
							if (dataset && dataset.data) {
								const dataLength = dataset.data.length;
								// 以起始 centerIndex 和 scale 计算锚点对应的数据索引
								const oldVisibleCount = Math.max(1, Math.floor(dataLength / startScale));
								const oldStartIndex = Math.max(0, Math.floor((startCenterIndex || Math.floor(dataLength/2)) - oldVisibleCount / 2));
								const indexAtPointer = Math.min(dataLength - 1, Math.max(0, Math.round(oldStartIndex + ratio * (oldVisibleCount - 1))));
								// 用 newScale 计算新的可视区,让 indexAtPointer 保持在同一屏幕位置
								const newVisibleCount = Math.max(1, Math.floor(dataLength / newScale));
								const newStartIndex = Math.max(0, Math.min(dataLength - newVisibleCount, Math.round(indexAtPointer - ratio * (newVisibleCount - 1))));
								const newCenter = Math.floor(newStartIndex + newVisibleCount / 2);
								this.centerIndex = Math.max(0, Math.min(dataLength - 1, newCenter));
							}
						}
						this.scale = newScale;
						this.drawChart();
					}).exec();
				}
			},
			
			getDistance(touch1, touch2) {
				const dx = touch1.pageX - touch2.pageX;
				const dy = touch1.pageY - touch2.pageY;
				return Math.sqrt(dx * dx + dy * dy);
			},
			
			// 重置视图
			resetView() {
				this.scale = 1;
				this.offset = 0;
				this.drawChart();
			}
		}
	};
</script>

<style scoped>
.chart-container {
	width: 100%;
	height: 100%;
	position: relative;
}

.chart-canvas {
	width: 100%;
	height: 100%;
}

.loading-mask {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	background-color: rgba(255, 255, 255, 0.8);
	display: flex;
	justify-content: center;
	align-items: center;
	font-size: 14px;
	color: #666;
}
</style>