基于 Canvas 2D 的「数脉引擎」——原生折线图组件开发与数据可视化全链路解析
文章目录

每日一句正能量
“不要因为未知而退缩,也不要因为困难而止步。”
未知不等于危险,困难不等于绝境。真正拦住你的,往往不是路本身,而是你对路的想象。
摘要
摘要:数据可视化是移动应用从「信息展示」迈向「智能决策」的关键桥梁。在 HarmonyOS NEXT 生态中,虽然第三方图表库(如 McCharts、HarmonyCharts)提供了开箱即用的方案,但面对定制化需求、极致性能与深度交互场景,原生 Canvas 自研仍是不可替代的技术路径。本文基于 HarmonyOS 6(API 23)最新 Canvas 2D 能力,从坐标系映射、贝塞尔曲线平滑、区域渐变填充、触摸命中检测到逐帧动画系统,深度拆解「数脉引擎」原生折线图组件的完整工程实现方案。通过配置驱动架构与渲染器分离设计,打造企业级高性能、可扩展的数据可视化解决方案。
一、前言:为什么需要自研折线图组件?
在鸿蒙应用开发中,数据可视化场景日益丰富:运营报表、健康趋势、股票行情、IoT 设备监控……折线图作为最基础也最重要的图表类型,承担着展示时间序列趋势的核心职责。然而,开发者在实际落地时往往面临以下抉择与痛点:
- 第三方库的局限:McCharts、HarmonyCharts 等库虽能快速出图,但在自定义交互手势、特殊动画效果、主题深度定制时灵活性不足,且引入额外依赖会增加包体积;
- 性能瓶颈:长周期数据(如 365 天股票走势)在 WebView 中渲染帧率骤降,而原生 Canvas 直接操作 GPU 渲染管线,性能提升 3~5 倍;
- 交互深度不足:业务需求常要求「长按显示详细 Tooltip」「双指缩放时间轴」「数据点拖拽编辑」等高级交互,第三方库往往无法直接支持;
- 多端一致性:同一套图表逻辑需要在手机、平板、车机等多设备上保持一致的渲染效果,自研组件可通过响应式布局统一适配。
本文以「数脉引擎」项目为载体,系统讲解如何基于 ArkTS + Canvas 2D 从零构建一套功能完备、性能优异、交互深度的原生折线图组件。
二、整体架构设计:四层分离,配置驱动

架构设计遵循「四层分离」原则:
| 层级 | 职责 | 核心模块 |
|---|---|---|
| UI 展示层 | Canvas 画布承载、图例、提示框、坐标轴标签 | Canvas、Legend、Tooltip、AxisLabel、Title |
| 业务逻辑层 | 图表状态管理、折线渲染调度、坐标计算、命中检测、动画引擎 | ChartViewModel、LineRenderer、AxisCalculator、HitTester、Animator |
| 渲染层 | Path2D 路径绘制、贝塞尔曲线、线性渐变、圆形绘制、画布清理 | Path2D、bezierCurveTo、createLinearGradient、arc、clearRect |
| 数据层 | 时间序列数据、多系列指标、配置参数、主题样式 | ChartData、SeriesData、ChartConfig、ChartTheme |
设计要点:采用「配置驱动」架构,所有视觉表现通过
ChartConfig对象描述,渲染器只负责执行绘制指令。这种设计使得同一套渲染引擎可以支持折线图、面积图、柱状图等多种图表类型的切换,只需更换配置即可。cite🛠web_search:24#4:~:text=配置驱动、全自定义、纯ArkTS打造柱状图/饼图/折线图
三、Canvas 2D 基础与坐标系映射
3.1 Canvas 组件初始化
HarmonyOS NEXT 提供的 Canvas 组件基于 Skia 图形引擎,支持完整的 2D 渲染 API。基础配置如下:
import { Canvas, CanvasRenderingContext2D, RenderingContextSettings } from '@kit.ArkUI';
@Entry
@Component
struct LineChartPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private canvasWidth: number = 0;
private canvasHeight: number = 0;
build() {
Column() {
Canvas(this.ctx)
.width('100%')
.height(400)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.onReady(() => {
this.canvasWidth = this.ctx.width;
this.canvasHeight = this.ctx.height;
this.renderChart();
})
.onTouch((event: TouchEvent) => {
this.handleTouch(event);
})
}
.width('100%')
.height('100%')
.padding(16)
}
}
关键配置:
RenderingContextSettings(true)开启抗锯齿,确保折线边缘平滑无锯齿。onReady回调中获取 Canvas 实际宽高,所有绘制坐标基于此计算。cite🛠web_search:24#8:~:text=private settings: RenderingContextSettings = new RenderingContextSettings(true)
3.2 坐标系映射原理
折线图的核心数学问题,是将「数据坐标系」(Data Space)中的数值映射到「Canvas 像素坐标系」(Pixel Space)。

interface ChartPadding {
top: number;
right: number;
bottom: number;
left: number;
}
class AxisCalculator {
private padding: ChartPadding = { top: 40, right: 20, bottom: 40, left: 50 };
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private dataMin: number = 0;
private dataMax: number = 100;
setCanvasSize(width: number, height: number): void {
this.canvasWidth = width;
this.canvasHeight = height;
}
setDataRange(min: number, max: number): void {
this.dataMin = min;
this.dataMax = max;
}
// 计算绘图区域
getChartArea(): { left: number; top: number; width: number; height: number } {
return {
left: this.padding.left,
top: this.padding.top,
width: this.canvasWidth - this.padding.left - this.padding.right,
height: this.canvasHeight - this.padding.top - this.padding.bottom
};
}
// 数据值 → Canvas Y 坐标(注意Canvas Y轴向下为正,需翻转)
valueToY(value: number): number {
const area = this.getChartArea();
const ratio = (value - this.dataMin) / (this.dataMax - this.dataMin);
return area.top + area.height * (1 - ratio);
}
// 数据索引 → Canvas X 坐标
indexToX(index: number, totalCount: number): number {
const area = this.getChartArea();
const stepX = area.width / (totalCount - 1);
return area.left + index * stepX;
}
// 计算「友好」的Y轴刻度(Nice Number算法)
calculateNiceTicks(count: number = 5): number[] {
const range = this.dataMax - this.dataMin;
const roughStep = range / count;
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
const normalizedStep = roughStep / magnitude;
let niceStep: number;
if (normalizedStep <= 1) niceStep = 1 * magnitude;
else if (normalizedStep <= 2) niceStep = 2 * magnitude;
else if (normalizedStep <= 5) niceStep = 5 * magnitude;
else niceStep = 10 * magnitude;
const niceMin = Math.floor(this.dataMin / niceStep) * niceStep;
const niceMax = Math.ceil(this.dataMax / niceStep) * niceStep;
const ticks: number[] = [];
for (let v = niceMin; v <= niceMax; v += niceStep) {
ticks.push(v);
}
return ticks;
}
}
Nice Number 算法:Y 轴刻度不能直接使用数据的最小/最大值,否则会出现「137.5」这类不友好的数字。通过上述算法,刻度总是落在 1、2、5 的倍数上,大幅提升可读性。cite🛠web_search:24#4:~:text=坐标轴和网格线绘制逻辑与柱状图类似
四、折线图核心渲染:从网格到数据点
4.1 网格线与坐标轴绘制
class LineChartRenderer {
private calculator: AxisCalculator;
private ctx: CanvasRenderingContext2D;
constructor(ctx: CanvasRenderingContext2D, calculator: AxisCalculator) {
this.ctx = ctx;
this.calculator = calculator;
}
// 绘制网格线
drawGrid(ticks: number[], labels: string[]): void {
const area = this.calculator.getChartArea();
this.ctx.lineWidth = 1;
// 横向网格线(对应Y轴刻度)
ticks.forEach(tick => {
const y = this.calculator.valueToY(tick);
this.ctx.beginPath();
this.ctx.strokeStyle = '#EEEEEE';
this.ctx.setLineDash([4, 4]); // 虚线
this.ctx.moveTo(area.left, y);
this.ctx.lineTo(area.left + area.width, y);
this.ctx.stroke();
});
// 纵向网格线(对应X轴标签)
labels.forEach((_, index) => {
const x = this.calculator.indexToX(index, labels.length);
this.ctx.beginPath();
this.ctx.strokeStyle = '#EEEEEE';
this.ctx.setLineDash([4, 4]);
this.ctx.moveTo(x, area.top);
this.ctx.lineTo(x, area.top + area.height);
this.ctx.stroke();
});
this.ctx.setLineDash([]); // 恢复实线
}
// 绘制坐标轴
drawAxes(ticks: number[], labels: string[]): void {
const area = this.calculator.getChartArea();
this.ctx.lineWidth = 1.5;
this.ctx.strokeStyle = '#BDBDBD';
// X轴
this.ctx.beginPath();
this.ctx.moveTo(area.left, area.top + area.height);
this.ctx.lineTo(area.left + area.width, area.top + area.height);
this.ctx.stroke();
// Y轴
this.ctx.beginPath();
this.ctx.moveTo(area.left, area.top);
this.ctx.lineTo(area.left, area.top + area.height);
this.ctx.stroke();
// Y轴标签
this.ctx.fillStyle = '#757575';
this.ctx.font = '12px sans-serif';
this.ctx.textAlign = 'right';
this.ctx.textBaseline = 'middle';
ticks.forEach(tick => {
const y = this.calculator.valueToY(tick);
this.ctx.fillText(tick.toString(), area.left - 8, y);
});
// X轴标签
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'top';
labels.forEach((label, index) => {
const x = this.calculator.indexToX(index, labels.length);
this.ctx.fillText(label, x, area.top + area.height + 8);
});
}
}
4.2 折线绘制:直线与贝塞尔平滑
折线图的视觉核心在于线条的绘制。基础版本使用 lineTo 连接各点,进阶版本使用三次贝塞尔曲线实现平滑效果。
interface DataPoint {
label: string;
value: number;
}
// 计算贝塞尔曲线控制点(Catmull-Rom 样条转 Cubic Bezier)
private calculateControlPoints(
points: { x: number; y: number }[],
tension: number = 0.5
): [Array<{ x: number; y: number }>, Array<{ x: number; y: number }>] {
const cp1: Array<{ x: number; y: number }> = [];
const cp2: Array<{ x: number; y: number }> = [];
const n = points.length;
const k = tension / 3;
for (let i = 0; i < n - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[Math.min(n - 1, i + 1)];
const p3 = points[Math.min(n - 1, i + 2)];
// 后向控制点 (CP1)
cp1.push({
x: p1.x + (p2.x - p0.x) * k,
y: p1.y + (p2.y - p0.y) * k
});
// 前向控制点 (CP2)
cp2.push({
x: p2.x - (p3.x - p1.x) * k,
y: p2.y - (p3.y - p1.y) * k
});
}
return [cp1, cp2];
}
// 绘制折线(支持平滑模式)
drawLine(data: DataPoint[], smooth: boolean = true, color: string = '#2196F3'): void {
if (data.length < 2) return;
const points = data.map((item, index) => ({
x: this.calculator.indexToX(index, data.length),
y: this.calculator.valueToY(item.value)
}));
this.ctx.beginPath();
this.ctx.strokeStyle = color;
this.ctx.lineWidth = 2.5;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.moveTo(points[0].x, points[0].y);
if (smooth && points.length > 2) {
const [cp1, cp2] = this.calculateControlPoints(points, 0.5);
for (let i = 0; i < points.length - 1; i++) {
this.ctx.bezierCurveTo(
cp1[i].x, cp1[i].y,
cp2[i].x, cp2[i].y,
points[i + 1].x, points[i + 1].y
);
}
} else {
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i].x, points[i].y);
}
}
this.ctx.stroke();
}
贝塞尔曲线原理:
bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)使用两个控制点定义曲线形状。tension = 0.5时,控制点位于相邻线段中点连线的 1/6 处,生成最自然的平滑效果。cite🛠web_search:24#8:~:text=计算后向控制点 (CP1)…计算前向控制点 (CP2)
五、区域填充与渐变效果
5.1 渐变区域填充
在折线图下方填充半透明渐变区域,可以直观展示数据量的「厚度」,是专业数据可视化的标配。
// 绘制区域填充(折线下方)
drawArea(data: DataPoint[], color: string = '#2196F3'): void {
if (data.length < 2) return;
const area = this.calculator.getChartArea();
const points = data.map((item, index) => ({
x: this.calculator.indexToX(index, data.length),
y: this.calculator.valueToY(item.value)
}));
// 创建线性渐变(从上到下,颜色渐变为透明)
const gradient = this.ctx.createLinearGradient(0, area.top, 0, area.top + area.height);
gradient.addColorStop(0, this.hexToRgba(color, 0.3));
gradient.addColorStop(1, this.hexToRgba(color, 0.0));
this.ctx.beginPath();
this.ctx.moveTo(points[0].x, area.top + area.height); // 左下角
// 沿折线路径绘制
for (let i = 0; i < points.length; i++) {
this.ctx.lineTo(points[i].x, points[i].y);
}
this.ctx.lineTo(points[points.length - 1].x, area.top + area.height); // 右下角
this.ctx.closePath();
this.ctx.fillStyle = gradient;
this.ctx.fill();
}
// 十六进制颜色转 rgba 字符串
private hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
渐变方向:
createLinearGradient(0, top, 0, bottom)创建垂直渐变,折线顶部颜色最深(alpha=0.3),底部完全透明(alpha=0.0),形成自然的「淡出」效果。cite🛠web_search:24#8:~:text=创建渐变填充…gradient.addColorStop(0, ‘rgba(135, 206, 235, 0.3)’)
六、触摸交互:命中检测与 Tooltip
6.1 命中检测算法
用户触摸 Canvas 时,需要判断触摸点「命中」了哪个数据点。核心算法是计算触摸点与所有数据点的欧氏距离,取最小值并与阈值比较。
class HitTester {
private pointPositions: Array<{ x: number; y: number }> = [];
private hitRadius: number = 15; // 命中半径(像素)
// 缓存数据点坐标(每次重绘后更新)
cachePoints(points: Array<{ x: number; y: number }>): void {
this.pointPositions = points;
}
// 命中测试:返回命中的数据点索引,未命中返回 -1
hitTest(touchX: number, touchY: number): number {
let minDistance = Infinity;
let hitIndex = -1;
for (let i = 0; i < this.pointPositions.length; i++) {
const pt = this.pointPositions[i];
// 欧氏距离 = sqrt(dx^2 + dy^2)
const distance = Math.hypot(touchX - pt.x, touchY - pt.y);
if (distance < this.hitRadius && distance < minDistance) {
minDistance = distance;
hitIndex = i;
}
}
return hitIndex;
}
}
6.2 Tooltip 绘制
命中数据点后,需要在对应位置绘制一个信息气泡,展示详细数据。
// 绘制高亮效果 + Tooltip
drawHighlight(index: number, data: DataPoint[], color: string = '#2196F3'): void {
if (index < 0 || index >= this.pointPositions.length) return;
const pt = this.pointPositions[index];
// 1. 放大高亮数据点
this.ctx.fillStyle = color;
this.ctx.beginPath();
this.ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 2;
this.ctx.beginPath();
this.ctx.arc(pt.x, pt.y, 8, 0, 2 * Math.PI);
this.ctx.stroke();
// 2. 绘制虚线圆环(强调效果)
this.ctx.strokeStyle = color;
this.ctx.lineWidth = 1;
this.ctx.setLineDash([3, 3]);
this.ctx.beginPath();
this.ctx.arc(pt.x, pt.y, 14, 0, 2 * Math.PI);
this.ctx.stroke();
this.ctx.setLineDash([]);
// 3. 绘制 Tooltip 气泡
const text = `${data[index].label}: ${data[index].value}`;
this.ctx.font = 'bold 13px sans-serif';
const textWidth = this.ctx.measureText(text).width;
const bubbleW = textWidth + 24;
const bubbleH = 32;
const bubbleX = pt.x - bubbleW / 2;
const bubbleY = pt.y - 50;
// 气泡背景(圆角矩形)
this.drawRoundRect(this.ctx, bubbleX, bubbleY, bubbleW, bubbleH, 6);
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
this.ctx.fill();
// 气泡文字
this.ctx.fillStyle = '#FFFFFF';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(text, pt.x, bubbleY + bubbleH / 2);
// 4. 绘制指向数据点的小三角
this.ctx.beginPath();
this.ctx.moveTo(pt.x - 6, bubbleY + bubbleH);
this.ctx.lineTo(pt.x, bubbleY + bubbleH + 6);
this.ctx.lineTo(pt.x + 6, bubbleY + bubbleH);
this.ctx.closePath();
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
this.ctx.fill();
}
// 绘制圆角矩形路径
private drawRoundRect(
ctx: CanvasRenderingContext2D,
x: number, y: number, w: number, h: number, r: number
): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
交互设计要点:命中区域扩大到
hitRadius = 15像素(数据点半径仅 4 像素),大幅提升触摸命中率。Tooltip 采用黑色半透明气泡 + 白色文字,确保在任何背景色下都清晰可读。cite🛠web_search:24#4:~:text=命中测试:计算触摸点与所有缓存数据点的距离…扩大命中区域,提升交互体验
七、动画系统:让折线「生长」起来
7.1 逐帧动画引擎
折线图的入场动画采用「从左到右生长」的效果,通过控制可见数据点的数量实现。
class ChartAnimator {
private startTime: number = 0;
private duration: number = 1000; // 动画时长(ms)
private isRunning: boolean = false;
private animationFrameId: number = -1;
start(
onProgress: (progress: number) => void,
onComplete: () => void,
duration: number = 1000
): void {
this.duration = duration;
this.startTime = Date.now();
this.isRunning = true;
const frame = () => {
if (!this.isRunning) return;
const elapsed = Date.now() - this.startTime;
const rawProgress = Math.min(elapsed / this.duration, 1);
// 应用缓动函数:EaseInOutCubic
const progress = this.easeInOutCubic(rawProgress);
onProgress(progress);
if (rawProgress < 1) {
this.animationFrameId = requestAnimationFrame(frame);
} else {
this.isRunning = false;
onComplete();
}
};
this.animationFrameId = requestAnimationFrame(frame);
}
// EaseInOutCubic 缓动:开始慢→中间快→结束慢
private easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
stop(): void {
this.isRunning = false;
if (this.animationFrameId !== -1) {
cancelAnimationFrame(this.animationFrameId);
}
}
}
7.2 动画与渲染联动
// 在 Chart 组件中整合动画
private startAnimation(): void {
this.animator.start(
(progress) => {
// 根据进度计算可见数据点数量
const visibleCount = Math.max(2, Math.ceil(this.data.length * progress));
this.visibleDataCount = visibleCount;
this.redraw(progress);
},
() => {
// 动画结束,确保绘制完整图形
this.redraw(1);
},
this.config.animationDuration ?? 1000
);
}
// 重绘方法
private redraw(progress: number): void {
if (this.canvasWidth === 0 || this.canvasHeight === 0) return;
// 1. 清空画布
this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
// 2. 绘制背景
this.ctx.fillStyle = this.config.backgroundColor ?? '#FFFFFF';
this.ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight);
// 3. 绘制网格与坐标轴
const ticks = this.calculator.calculateNiceTicks();
this.renderer.drawGrid(ticks, this.labels);
this.renderer.drawAxes(ticks, this.labels);
// 4. 绘制折线(仅绘制可见部分)
const visibleData = this.data.slice(0, this.visibleDataCount);
this.renderer.drawLine(visibleData, this.config.smooth ?? true, this.config.lineColor);
// 5. 绘制区域填充
if (this.config.fillArea) {
this.renderer.drawArea(visibleData, this.config.lineColor);
}
// 6. 绘制数据点
this.renderer.drawPoints(visibleData, this.config.lineColor);
// 7. 绘制高亮(如果有选中的数据点)
if (this.selectedIndex !== -1 && this.selectedIndex < this.visibleDataCount) {
this.renderer.drawHighlight(this.selectedIndex, this.data, this.config.lineColor);
}
}
动画原理:
visibleCount = Math.ceil(n * progress)控制每帧绘制的数据点数量,配合EaseInOutCubic缓动函数,折线呈现出「先快后慢」的自然生长效果,比线性动画更具视觉吸引力。cite🛠web_search:24#4:~:text=动画系统…根据动画进度,决定绘制多少个点
八、多系列折线图与图例
8.1 多系列数据渲染
interface SeriesData {
name: string;
color: string;
data: DataPoint[];
}
// 绘制多系列折线图
renderMultiSeries(seriesList: SeriesData[]): void {
// 1. 计算所有系列的数据范围,统一Y轴刻度
let globalMin = Infinity;
let globalMax = -Infinity;
seriesList.forEach(series => {
series.data.forEach(item => {
globalMin = Math.min(globalMin, item.value);
globalMax = Math.max(globalMax, item.value);
});
});
this.calculator.setDataRange(globalMin, globalMax);
// 2. 绘制每个系列
seriesList.forEach(series => {
if (this.config.fillArea) {
this.renderer.drawArea(series.data, series.color);
}
this.renderer.drawLine(series.data, this.config.smooth ?? true, series.color);
this.renderer.drawPoints(series.data, series.color);
});
}
// 绘制图例
@Builder
LegendView(seriesList: SeriesData[]) {
Row() {
ForEach(seriesList, (series: SeriesData) => {
Row() {
Circle({ width: 10, height: 10 })
.fill(series.color)
Text(series.name)
.fontSize(12)
.fontColor('#424242')
.margin({ left: 4 })
}
.margin({ right: 16 })
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 8, bottom: 8 })
}
九、性能优化与最佳实践
Canvas 2D 渲染虽然性能优异,但在大数据量场景下仍需针对性优化:
| 优化项 | 具体方案 | 预期效果 |
|---|---|---|
| 坐标缓存 | 数据点坐标在首次计算后缓存,避免每帧重复计算 | 减少 40% CPU 占用 |
| 脏矩形渲染 | 仅重绘变化区域(如 Tooltip 区域),而非全量 clearRect | 大数据量场景帧率提升 2 倍 |
| 数据降采样 | 超过 200 个数据点时,使用 LTTB 算法降采样 | 保持视觉形状的同时减少 80% 绘制点 |
| 离屏 Canvas | 静态元素(网格、坐标轴)预渲染到离屏 Canvas | 每帧只需绘制动态折线 |
| 避免状态触发 | 动画过程中不更新 @State,直接操作 Canvas | 绕过 ArkUI 状态管理开销 |
// LTTB(Largest Triangle Three Buckets)降采样算法
private lttbDownsample(data: DataPoint[], threshold: number): DataPoint[] {
if (data.length <= threshold) return data;
const sampled: DataPoint[] = [];
let sampledIndex = 0;
const a = 0; // 始终保留第一个点
sampled[sampledIndex++] = data[a];
for (let i = 0; i < threshold - 2; i++) {
const avgRangeStart = Math.floor((data.length - 1) * i / (threshold - 2)) + 1;
const avgRangeEnd = Math.floor((data.length - 1) * (i + 1) / (threshold - 2)) + 1;
const avgRangeLength = avgRangeEnd - avgRangeStart;
let avgX = 0, avgY = 0;
for (let j = avgRangeStart; j < avgRangeEnd; j++) {
avgX += j;
avgY += data[j].value;
}
avgX /= avgRangeLength;
avgY /= avgRangeLength;
const rangeOffs = Math.floor((data.length - 1) * i / (threshold - 2)) + 1;
const rangeTo = Math.floor((data.length - 1) * (i + 1) / (threshold - 2)) + 1;
let maxArea = -1;
let maxIdx = rangeOffs;
for (let j = rangeOffs; j < rangeTo; j++) {
const area = Math.abs(
(data[a].value - avgY) * (j - avgX) - (data[a].value - data[j].value) * (avgRangeStart - avgX)
);
if (area > maxArea) {
maxArea = area;
maxIdx = j;
}
}
sampled[sampledIndex++] = data[maxIdx];
a = maxIdx;
}
sampled[sampledIndex++] = data[data.length - 1]; // 保留最后一个点
return sampled;
}
十、触摸交互与动画系统流程

十一、完整折线图 UI 效果

十二、总结与展望
本文从架构设计、Canvas 2D 基础、坐标系映射、折线渲染、贝塞尔平滑、区域渐变填充、触摸命中检测、Tooltip 绘制到逐帧动画系统,完整拆解了 HarmonyOS 6 下「数脉引擎」原生折线图组件的开发全流程。核心要点回顾:
- 配置驱动架构:
ChartConfig统一描述所有视觉表现,渲染器只负责执行,实现图表类型快速切换; - 坐标映射精准:Nice Number 算法生成友好刻度,数据值到像素坐标的线性映射确保视觉准确;
- 贝塞尔平滑自然:Catmull-Rom 样条转 Cubic Bezier, tension 参数控制平滑度,折线过渡如丝般顺滑;
- 交互体验细腻:15 像素命中半径 + 虚线圆环高亮 + 黑色 Tooltip 气泡,触摸反馈即时精准;
- 动画系统流畅:EaseInOutCubic 缓动 + 可见点数量控制,折线生长动画自然优雅;
- 性能优化到位:坐标缓存、脏矩形渲染、LTTB 降采样、离屏 Canvas,保障大数据量场景下的 60fps。
未来可进一步探索的方向包括:HarmonyOS 分布式能力实现「手机查看趋势、平板深度分析」的跨设备图表联动、基于端侧 AI 的异常数据点自动标注与趋势预测、以及接入 ArkGraphics 3D 能力打造沉浸式 3D 折线地形图。
转载自:https://blog.csdn.net/u014727709/article/details/163449638
欢迎 👍点赞✍评论⭐收藏,欢迎指正
更多推荐


所有评论(0)