HarmonyOS 6.0 数据可视化图表
图表是数据展示最直观的方式。HarmonyOS没有内置的图表库(没有echarts那种),图表要么用Canvas自己画,要么用Progress/DataPanel这类简化组件。这篇把柱状图、折线图、饼图、环形图全部用Canvas实现一遍,顺便讲清楚绘图流程。
Canvas基础
Canvas绘图需要RenderingContext2D:
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
build() {
Canvas(this.context)
.width('100%')
.height(200)
.onReady(() => {
this.drawChart();
})
}
onReady是关键——Canvas组件准备完毕后才能绑定context绘图。在aboutToAppear里画是画不出来的,因为Canvas还没挂载。RenderingContextSettings(true)表示开启抗锯齿。
坐标系
Canvas坐标系左上角是(0,0),x向右增长,y向下增长。画图表需要先translate到绘图区域起点:
let ctx: CanvasRenderingContext2D = this.context;
let padding: number = 30;
let chartWidth: number = 320 - padding * 2;
let chartHeight: number = 200 - padding * 2;
padding给坐标轴和标签留空间。chartWidth/chartHeight是实际绘图区域。
柱状图

private drawBarChart(): void {
let ctx: CanvasRenderingContext2D = this.context;
let padding: number = 30;
let w: number = 320;
let h: number = 200;
let chartW: number = w - padding * 2;
let chartH: number = h - padding * 2;
let data: number[] = [65, 45, 80, 55, 90, 70];
let maxVal: number = 100;
let barWidth: number = chartW / data.length * 0.6;
let gap: number = chartW / data.length;
// 清空画布
ctx.clearRect(0, 0, w, h);
// 画网格线
ctx.strokeStyle = '#eeeeee';
ctx.lineWidth = 1;
for (let i: number = 0; i <= 4; i++) {
let y: number = padding + (chartH / 4) * i;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(w - padding, y);
ctx.stroke();
}
// 画柱子
let colors: string[] = ['#1a73e8', '#43a047', '#ff9800', '#e53935', '#9c27b0', '#00897b'];
for (let i: number = 0; i < data.length; i++) {
let x: number = padding + gap * i + (gap - barWidth) / 2;
let barH: number = data[i] / maxVal * chartH;
let y: number = padding + chartH - barH;
ctx.fillStyle = colors[i % colors.length];
ctx.fillRect(x, y, barWidth, barH);
// 数据标签
ctx.fillStyle = '#333333';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(data[i].toString(), x + barWidth / 2, y - 5);
}
}
clearRect清空画布是必要的第一步——否则重绘时旧内容不会消失。fillRect画柱子,fillText画标签。每根柱子的x坐标根据gap和barWidth计算。
折线图

private drawLineChart(): void {
let ctx: CanvasRenderingContext2D = this.context;
let padding: number = 30;
let w: number = 320;
let h: number = 200;
let chartW: number = w - padding * 2;
let chartH: number = h - padding * 2;
let data: number[] = [65, 45, 80, 55, 90, 70];
let maxVal: number = 100;
ctx.clearRect(0, 0, w, h);
// 网格线
ctx.strokeStyle = '#eeeeee';
ctx.lineWidth = 1;
for (let i: number = 0; i <= 4; i++) {
let y: number = padding + (chartH / 4) * i;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(w - padding, y);
ctx.stroke();
}
// 折线
ctx.strokeStyle = '#1a73e8';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i: number = 0; i < data.length; i++) {
let x: number = padding + (chartW / (data.length - 1)) * i;
let y: number = padding + chartH - (data[i] / maxVal * chartH);
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
// 数据点
ctx.fillStyle = '#1a73e8';
for (let i: number = 0; i < data.length; i++) {
let x: number = padding + (chartW / (data.length - 1)) * i;
let y: number = padding + chartH - (data[i] / maxVal * chartH);
ctx.beginPath();
ctx.arc(x, y, 4, 0, 6.28);
ctx.fill();
}
}
moveTo画起点,lineTo画后续点,stroke连成线。arc画圆点,6.28≈2π画完整圆。折线图的核心是坐标转换:数据值→Canvas坐标。
面积图
面积图在折线图基础上加填充:
// 在折线stroke之后
ctx.fillStyle = 'rgba(26, 115, 232, 0.15)';
ctx.beginPath();
ctx.moveTo(padding, padding + chartH); // 左下角
for (let i: number = 0; i < data.length; i++) {
let x: number = padding + (chartW / (data.length - 1)) * i;
let y: number = padding + chartH - (data[i] / maxVal * chartH);
ctx.lineTo(x, y);
}
ctx.lineTo(padding + chartW, padding + chartH); // 右下角
ctx.closePath();
ctx.fill();
关键:先从左下角moveTo,然后lineTo所有数据点,最后lineTo右下角,closePath闭合路径,fill填充半透明色。
饼图
饼图用arc画扇区,每个扇区的起始角度根据数据占比计算:
private drawPieChart(): void {
let ctx: CanvasRenderingContext2D = this.context;
let centerX: number = 160;
let centerY: number = 120;
let radius: number = 80;
let values: number[] = [35, 25, 20, 20];
let colors: string[] = ['#1a73e8', '#43a047', '#ff9800', '#e0e0e0'];
let labels: string[] = ['技术', '设计', '运营', '其他'];
let total: number = 0;
for (let i: number = 0; i < values.length; i++) { total += values[i]; }
ctx.clearRect(0, 0, 320, 240);
let startAngle: number = -Math.PI / 2; // 从12点方向开始
for (let i: number = 0; i < values.length; i++) {
let sweepAngle: number = (values[i] / total) * 2 * Math.PI;
ctx.fillStyle = colors[i];
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sweepAngle);
ctx.closePath();
ctx.fill();
// 标签
let midAngle: number = startAngle + sweepAngle / 2;
let labelX: number = centerX + Math.cos(midAngle) * (radius + 20);
let labelY: number = centerY + Math.sin(midAngle) * (radius + 20);
ctx.fillStyle = '#333333';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(labels[i] + ' ' + values[i].toString() + '%', labelX, labelY);
startAngle += sweepAngle;
}
}
起始角度-PI/2是12点方向(默认3点方向是0)。sweepAngle根据数据占比计算。每个扇区:moveTo圆心→arc画弧→closePath闭合→fill填充。
标签位置在扇区中间角度、半径+20的位置。cos/sin计算极坐标到直角坐标。
环形图(甜甜圈图)
环形图就是饼图中间挖个圆:
// 画完饼图后
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(centerX, centerY, radius * 0.6, 0, 2 * Math.PI);
ctx.fill();
// 中间文字
ctx.fillStyle = '#333333';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('100%', centerX, centerY + 6);
用背景色画一个更小的圆覆盖饼图中心。0.6倍半径形成甜甜圈效果。
DataPanel组件
DataPanel是内置的环形图组件,比Canvas简单得多:
DataPanel({ values: [30, 20, 15, 35], max: 100, type: DataPanelType.Circle })
.width(160)
.height(160)
.colors([[0x1a73e8, 0], [0x43a047, 0.3], [0xff9800, 0.5], [0xe53935, 0.8]])
DataPanelType有两个值:Circle(环形)和Line(线形)。values是各段数值,max是总值。colors数组中每个元素是[color, offset]。
但DataPanel功能有限——不支持标签、动画、交互。要做复杂图表还是得用Canvas。
图表动画
图表数据变化时加动画效果:
@State animProgress: number = 0;
aboutToAppear(): void {
setTimeout(() => { this.animProgress = 1; }, 100);
}
// 在drawBarChart中
let barH: number = data[i] / maxVal * chartH * this.animProgress;
animProgress从0到1,柱子高度乘以这个系数实现从0增长到目标高度的动画。用animation修饰器或animateTo驱动animProgress变化。
更精细的控制用setInterval:
private startAnimation(): void {
this.animProgress = 0;
let startTime: number = Date.now();
let duration: number = 800;
let timer: number = setInterval(() => {
let elapsed: number = Date.now() - startTime;
if (elapsed >= duration) {
this.animProgress = 1;
clearInterval(timer);
} else {
this.animProgress = elapsed / duration;
}
this.context.clearRect(0, 0, 320, 200);
this.drawBarChart();
}, 16);
}
每16ms一帧(60fps),计算进度,重绘Canvas。setInterval在aboutToDisappear中clearInterval清理。
点击交互
Canvas本身不处理点击,需要用onClick计算点击位置对应哪个数据:
Canvas(this.context)
.width('100%')
.height(200)
.onReady(() => { this.drawChart(); })
.onClick((event: ClickEvent) => {
let x: number = event.x;
let y: number = event.y;
// 判断点击了哪个柱子/数据点
this.handleChartClick(x, y);
})
event.x/y是点击位置,跟绘图坐标对应。判断哪个柱子被点击:计算x落在哪个gap区间。
绘图性能
Canvas每次重绘都要清空重画——没有局部更新能力。大数据量时注意:
- 避免频繁重绘:数据不变就不调draw方法
- 减少路径数量:多条线合用一个beginPath
- 离屏Canvas缓存:OffscreenCanvas预渲染静态部分
- 降低帧率:动画场景30fps就够了,不必60fps
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| Canvas空白 | 在onReady之前绘图 | 移到onReady回调里 |
| 重绘时残影 | 没clearRect | 每次draw前clearRect |
| arc画圆不闭合 | 没closePath | beginPath→arc→closePath→fill |
| 文字对不齐 | textAlign默认start | 设textAlign=‘center’ |
| 坐标偏移 | 没考虑padding | 所有坐标加上padding偏移 |
| 饼图从3点开始 | 起始角度0是3点方向 | 用-PI/2从12点开始 |
| DataPanel颜色不对 | colors格式错 | [[color, offset], …]不是[color, offset] |
| 动画卡顿 | setInterval没清 | aboutToDisappear中clearInterval |
| onClick坐标不准 | Canvas有缩放 | 用display.densityPixels换算 |
| fillText乱码 | font格式不对 | 用’14px sans-serif’格式 |
图表开发的核心是坐标转换——把数据值映射到Canvas像素坐标。搞清楚padding、chartWidth、chartHeight的计算,任何图表都能画出来。
更多推荐



所有评论(0)