问题背景与分析
移动端图表渲染百万级数据面临性能瓶颈,传统图表库无法处理超大规模数据集。LightningChart虽支持高性能渲染,但移动端兼容性差。解决方案需结合数据采样与动态缩放技术,平衡性能与细节展示。
核心实现思路
数据采样算法 采用LTTB(Largest-Triangle-Three-Buckets)降采样算法,保留原始数据的关键形态特征。
废话少说,先看实战,这是100万数据, 这里我是2k 的采样率,也就是1秒2000个点的数据, 一分钟就是12w 的数据
<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>