鸿蒙Canvas 图表设计:柱状图、折线图、饼图三种形态的数据与坐标系



一、设计理念:图表是数据的最诚实投影
图表是自定义绘制的「经典主场」:组件化的图表库(如 echarts)要引入大依赖、定制难;而 Canvas 手绘图表轻、可控、无依赖,还能获得第一手的绘制原理。本应用用 Canvas 实现三种最常用的图表形态:
| 形态 | 用途 | 本次数据 |
|---|---|---|
| 柱状图 | 离散分类的数值对比 | 一周销量(件) |
| 折线图 | 连续趋势变化 | 一周步数(千步) |
| 饼图 | 整体构成占比 | 时间分配(%) |
技术亮点:坐标系与网格、渐变柱体、绘制动画、扇区触摸命中——把 Canvas 的路径、样式、变换、动画 API 串成一套完整实践。
为什么第三个应用选图表
签名板(应用 1)和涂鸦画板(应用 2)解决的是「自由绘制」,画面由用户手指产生。图表应用则完全不同:画面由数据产生,用户只是查看者。这个转变带来三个全新的技术课题:
- 坐标系反转:前两个应用直接使用 Canvas 的屏幕坐标(原点左上、y 向下);图表需要数学坐标(原点左下、y 向上)——必须自己建立「数据域 → 像素域」的映射;
- 精确几何:笔迹可以「差不多就行」,图表的柱子、刻度、标签必须精确对齐——一像素的偏差都影响可读性;
- 主动动画:前两个应用的画面由用户操作触发重绘;图表的折线生长动画是程序主动驱动的——引入计时器与进度状态。
这三个课题分别对应本系列后续三篇的技术主线:映射(3-2 柱状图)、几何精度(3-3 折线图)、动画与交互(3-4 饼图)。图表是「从自由绘制走向工程绘制」的桥梁。
为什么不用组件化图表库
开发图表应用时,很多人第一反应是引入 echarts 等组件库。本应用刻意不用库、纯手绘,理由有三:
| 理由 | 说明 |
|---|---|
| 学习价值 | 手绘让你理解图表的本质(数据 → 图形的映射),库只是把映射封装了 |
| 轻量可控 | 零依赖、体积小、样式完全自主,适合嵌入业务页面的轻量场景 |
| 教学一致性 | 本系列 5 个应用全部用 CanvasRenderingContext2D,保持知识体系连贯 |
当然,真实产品里图表复杂(海量数据、3D、地图)时组件库仍是务实选择——本节结尾的对比表会给出选型建议。
二、数据模型:三组静态数据
图表先有数据再有图形。三组数据直接定义成常量:
interface BarItem {
name: string;
value: number;
}
interface PieItem {
name: string;
value: number;
color: string;
}
const BAR_DATA: BarItem[] = [
{ name: '周一', value: 120 }, { name: '周二', value: 200 },
{ name: '周三', value: 150 }, { name: '周四', value: 280 },
{ name: '周五', value: 180 }, { name: '周六', value: 320 },
{ name: '周日', value: 240 }
];
const LINE_DATA: number[] = [10, 26, 18, 42, 33, 55, 48];
const PIE_DATA: PieItem[] = [
{ name: '学习', value: 35, color: '#3B82F6' },
{ name: '工作', value: 30, color: '#10B981' },
{ name: '运动', value: 20, color: '#F59E0B' },
{ name: '娱乐', value: 15, color: '#EF4444' }
];
模型设计要点:
- 柱状图需要 name + value:x 轴要显示分类名;
- 饼图必须带 color:每个扇区独占颜色,色板内置在数据里——UI 图例也复用这份数据,数据是唯一的真相源;
- 折线图只有数值:x 轴是等距下标,配合
LINE_NAMES展示名称。
三、坐标系:画布没有坐标系,要自己建
Canvas 的原点是画布左上角,y 轴向下。而图表的 y 轴向上、原点在左下——坐标系必须自己换算。这是图表绘制与自由绘画最大的不同。
3.1 绘图区(plot 区)
const left = 40, right = 16, top = 24, bottom = 30;
const plotW = w - left - right; // 绘图区宽度
const plotH = h - top - bottom; // 绘图区高度
四边留白分别服务:左侧放 y 轴刻度文字(40)、右侧留呼吸(16)、上方留标题(24)、下方放 x 轴标签(30)。绘图区 = 画布 − 四边留白,所有图形元素都画在 plot 区内。
3.2 数值 → 像素的映射
柱状图的 y 轴映射:数据值 v 属于 [0, maxY],像素 y 属于 [bottom, h−bottom]:
const y = h - bottom - plotH * v / maxY; // 值越大,y 越小(越靠上)
const bh = plotH * v / maxY; // 柱高
两个比例项:plotH * v / maxY 是「值占最大值比例 × 绘图区高度」,从底边向上减得到柱顶 y。所有图表绘制的本质都是这种「数据域 → 像素域」的线性映射。
3.3 坐标轴「好看」的上限:niceMax
数据最大值 320 直接用的话,柱子顶到绘图区边缘不好看。约定「最大值 × 1.25 向上取 50 倍数」:
private niceMax(arr: number[]): number {
let m = 0;
for (const v of arr) {
if (v > m) {
m = v;
}
}
const raw = m * 1.25;
return Math.ceil(raw / 50) * 50;
}
320 → raw=400 → ceil(400/50)*50=400;280 → 350 → 350。网格按 4 等分就能整除,刻度值全是整数——这是「图表好看」的隐藏工程。
四、页面架构:一个画布,三种投影
@State tab: number = 0; // 0 柱状 / 1 折线 / 2 饼图
private redrawCurrent(): void {
if (this.canvasW === 0) {
return;
}
this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
if (this.tab === 0) {
this.drawBar();
} else if (this.tab === 1) {
this.drawLine();
} else {
this.drawPie();
}
}
关键决策:画布只有一个,切换 tab 只是换绘制函数——与涂鸦画板「一个画布 + 绘制参数」同构。好处:
- 上下文零重建:Canvas 组件一直存在,onReady 只触发一次;
- 绘制函数可组合:三套绘制逻辑互相独立,互不污染;
- 动画好控制:折线图的动画计时器只需要管「当前画哪种图」。
五、触摸交互的「反绘制」
绘制是把数据变成像素,触摸是把像素反推回数据——两者互为逆运算:
| 图表 | 正向(绘制) | 反向(命中检测) |
|---|---|---|
| 柱状图 | value → 柱体 (x, y, w, h) | 触摸 x → slot 下标 → 数据项 |
| 折线图 | index → 点 (x, y) | 触摸 x → 最近点下标 |
| 饼图 | value → 扇区角度 | 触摸向量 → atan2 角度 → 扇区 |
每个图表配一个「命中检测」函数,触摸 Down 时执行并触发重绘(高亮/外弹)。交互 = 命中检测 + 状态 + 重绘,与绘制层保持同样的「数据驱动」范式。
5.1 为什么图表用 Down 而不是 Move 做交互
三种图表的交互都是「点选」语义——点一下柱子看高亮、点一下扇区看外弹。Down 触发一次即可,为什么不用 Move 连续跟踪?
- 交互语义:图表是「查看」工具,不是「绘制」工具,用户的操作是「选择」而非「拖动」,Down 足够;
- 性能:Move 事件 60~120Hz 触发,每次都要做命中检测 + 重绘,纯属浪费;Down 一帧一次,压力为零;
- 一致性:三种图表的命中检测都挂在 Down 上,代码结构统一,便于维护。
如果将来要做「tooltip 跟随手指移动」的效果,再在 Move 里做最近点查询即可——那是叠加层,不影响 Down 的选中语义。
5.2 命中检测的「两级过滤」思想
饼图的命中检测是两级过滤(先距离后角度),柱状图只有一级(槽位除法)。这里把两级过滤的思想展开:
第一级:粗筛(计算量小)—— 点在圆内吗?点在绘图区内吗?
第二级:细判(精确匹配)—— 角度落在哪个扇区?x 落在哪个槽位?
粗筛的价值在于「用最便宜的运算排除大多数无效点」:饼图里圆外的触摸点占了大多数,一次距离比较就能排除,避免进入耗时的角度遍历。这个「先粗后细」的过滤模式在图形学里普遍存在(包围盒 → 精确相交),值得形成习惯。
六、文章小结
本节完成图表应用的设计:三种图表形态的数据模型(name+value / 数值数组 / 带色饼图数据)→ 坐标系(四边留白 + 数值像素线性映射 + niceMax 上限)→ 单画布三投影架构 → 触摸命中是绘制的逆运算。核心方法论是「数据域 ↔ 像素域」的显式映射——图表的一切图形都是这条映射的产物。
下一节实现柱状图:坐标轴、虚线网格、渐变柱体与数值标签。
七、补充:FAQ
Q1:为什么 x 轴标签单独留 30 的底部空间?
柱状图 x 轴 7 个中文标签(「周一」…)若贴着绘图区画会与坐标轴文字重叠。底部 30 的留白是「标签保护区」,这是图表布局的常规做法——留白不是浪费,是排版的呼吸。
Q2:niceMax 为什么乘 1.25?
图表惯例是「数据最高点不顶天」,留出 20% 的余量让柱顶到顶线之间有空白,视觉更稳。1.25 = 1 / 0.8,即「给数据留 80% 的空间」。
Q3:饼图颜色写死在数据里,不改行吗?
行,但写死有两个好处:图例直接复用数据(单一真相源)、扇区颜色稳定不随机。真实产品会从色板数组按序取色,本质相同。
八、深入:为什么图表是「数据域 ↔ 像素域」的映射
这一节把 3-1 的核心方法论展开,帮助你把「图表绘制」从「画几根柱子」提升到「任意图表都会画」的层面。
8.1 映射的两端:数据域与像素域
任何图表本质上都是两个集合之间的双射(或近似双射):
| 域 | 是什么 | 例子 |
|---|---|---|
| 数据域 | 业务语义的值 | 销量 120 件、占比 35%、星期名 |
| 像素域 | 画布上的坐标与尺寸 | x=100vp, y=200vp, 宽 20vp |
图表的「绘制函数」= 数据域 → 像素域 的映射;「命中检测」= 像素域 → 数据域 的逆映射。只要把这两个方向的映射函数写清楚,任何图表形态都能画出来。
8.2 线性映射的通用公式
几乎所有图表(柱、线、散点、面积)的核心都是同一个线性映射:
px = left + (v - vMin) / (vMax - vMin) * plotW
left:绘图区左边界(平移项);(v - vMin) / (vMax - vMin):数据值在值域内的归一化比例(0~1);* plotW:把比例放大到像素宽度。
柱状图的 y 映射就是它的特例(vMin=0、y 轴向下取反):
py = h - bottom - plotH * v / maxY
理解这个公式,你就能自己设计「对数坐标轴」「百分比堆叠」等高级图表——它们只是换了 归一化函数(取 log、按比例缩放)。
8.3 为什么绘制与命中要「共享同一套映射」
本应用反复强调「命中 = 绘制的逆运算」,其工程价值在于单一真相源:
- 如果绘制用
x = left + slot*i + (slot-barW)/2,命中却用另一套公式算槽位,两套公式一旦不一致,就会出现「柱子画在 A 处、点 B 处却高亮」的错位 bug; - 正确做法是把「槽位宽」「绘图区边界」等几何参数提取成方法(如
slotWidth()),绘制与命中都调用它——几何参数只定义一次。
这就是「绘制与命中互为逆运算」的具体落地:不是「画完再猜」,而是「用同一把尺子量」。
8.4 数据驱动的动画与交互
图表的所有视觉变化(高亮、外弹、动画进度)都源自状态变量(barActive / pieActive / lineProgress),绘制函数每次重绘时读取状态并投影——这与前两个应用「画面 = 数据的投影」完全同构,只是数据从「笔迹数组」变成了「图表状态」。
8.5 本应用与组件化图表库的对比
| 维度 | 本应用(手绘) | echarts 等组件库 |
|---|---|---|
| 依赖体积 | 零依赖 | 大(数百 KB) |
| 定制自由度 | 完全可控 | 受库 API 约束 |
| 学习成本 | 需理解绘制原理 | 需学库的配置体系 |
| 动画控制 | 自管 setInterval | 库内置 |
| 适用场景 | 教学、轻量嵌入 | 企业级复杂大屏 |
手绘图表的「轻 + 可控」正是本系列选它的理由;但真实产品中如果图表形态复杂(3D、地图、海量点),组件库仍是更务实的选择——技术选型没有对错,只有场景。
九、动手练习
学完设计篇,建议动手验证以下四点:
- 改数据验证映射:把 BAR_DATA 的最大值改成 999,观察 niceMax 如何把轴上限变成 1250(999×1.25=1248.75 → ceil(1248.75/50)×50=1250),验证「好看上限」算法;
- 改留白验证布局:把 left 从 40 改成 10,观察 y 轴刻度文字被裁切——理解「留白 = 标签保护区」;
- 负值数据实验:把某个 value 改成 -50,观察当前映射公式的行为(柱体向下画),思考需要什么改动(vMin 不再为 0);
- 手动投影练习:不写代码,用纸笔画一个「x 从 0 到 100、像素区宽 200」的映射表,算出 v=37 对应的 px——体会「归一化 × 缩放 + 平移」的每一步。
十、深入思考:三种图表的共性抽象
学完三张图的绘制后,回头看它们的共性,你会得到一个「图表绘制通用流水线」:
数据 → 归一化(数据域→比例) → 映射(比例→像素) → 绘制(路径/填充/文字)
↑
命中(像素→比例→数据)
| 阶段 | 柱状图 | 折线图 | 饼图 |
|---|---|---|---|
| 归一化 | v/maxY | v/maxY | v/total |
| 映射 | 线性→矩形 | 线性→点 | 线性→角度 |
| 绘制 | fillRect | lineTo+arc | arc×2 |
| 命中 | 槽位除法 | 最近点 | 距离+角度 |
四张表并排看,你会发现三张图只有「映射目标形状」不同,其余全部相同。这就是为什么掌握「数据域 ↔ 像素域」这条主线后,任何图表(雷达图、散点图、热力图)都能触类旁通。
十一、关于「诚实投影」的进一步讨论
本节标题叫「图表是数据的最诚实投影」,这句话有两层含义,值得展开:
- 技术层面:画面 = 数据的函数,数据变画面变,无任何人为修饰——这是数据驱动绘制的技术承诺;
- 伦理层面:图表设计有个经典陷阱叫「说谎的图表」——通过截断 y 轴起点(从 80 而不是 0 开始)、不等比例坐标轴、误导性的面积对比,可以让数据「看起来」支持某个观点。作为图表绘制者,理解「投影」的本质意味着你有能力识别并避免这些陷阱:诚实图表的第一原则是坐标轴比例真实、数据变换透明。
这一层思考把「怎么画」提升到「该不该这样画」,是图表应用与普通绘制应用的本质区别——图表的读者会信任它,绘制者因此负有责任。
十二、本篇知识地图
把 3-1 涉及的概念整理成一张「从数据到屏幕」的完整链路图,作为阅读后续三篇(3-2/3-3/3-4)的导航:
┌─────────────────────────────────────────────────────┐
│ 数据域(业务语义) │
│ BAR_DATA / LINE_DATA / PIE_DATA │
├─────────────────────────────────────────────────────┤
│ 归一化(把数据变成 0~1 比例) │
│ v/maxY(柱、线) v/total(饼) │
├─────────────────────────────────────────────────────┤
│ 映射(把比例变成像素) │
│ 线性 → 矩形坐标(柱) 线性 → 点(线) 线性 → 角度(饼)│
├─────────────────────────────────────────────────────┤
│ 绘制(把几何变成画面) │
│ fillRect / lineTo+arc / arc 双弧 + fill │
├─────────────────────────────────────────────────────┤
│ 命中(把触摸变回数据) │
│ 槽位除法 / 最近点 / 距离+角度 │
└─────────────────────────────────────────────────────┘
五层结构从上到下依次消费、从下到上依次反推。学习 3-2~3-4 时,每篇都沿着这条链路走一遍:数据是什么 → 怎么归一化 → 怎么映射 → 怎么画 → 怎么命中——任何图表都逃不出这五步。这就是「设计篇」的价值:它给了你一个分析所有图表的统一框架。
柱状图实战:坐标轴、网格线与渐变柱体
一、目标
用纯 Canvas 画出专业感的柱状图:坐标轴 + 虚线网格 + 渐变柱体 + 数值标签 + 触摸高亮。绘制顺序讲究「从背景到前景」:
网格(最底) → 坐标轴 → 柱体(含渐变) → 数值标签 → x 轴名称
二、网格:setLineDash 画虚线刻度线
// 网格(虚线 4 等分)
this.ctx.strokeStyle = '#E5E7EB';
this.ctx.lineWidth = 1;
this.ctx.setLineDash([4, 4]);
this.ctx.font = '11vp sans-serif';
this.ctx.textAlign = 'right';
this.ctx.textBaseline = 'middle';
for (let i = 0; i <= 4; i++) {
const y = top + plotH - plotH * i / 4; // 4 等分刻度线
this.ctx.beginPath();
this.ctx.moveTo(left, y);
this.ctx.lineTo(w - right, y);
this.ctx.stroke();
this.ctx.fillStyle = '#9CA3AF';
this.ctx.fillText(String(Math.round(maxY * i / 4)), left - 6, y); // y 轴刻度文字
}
this.ctx.setLineDash([]); // 用完复位,防止影响坐标轴
三个要点:
- setLineDash([4, 4]):4 像素实线 + 4 像素空白循环——虚线网格既不抢柱体视觉,又提供读数参考。用完必须
setLineDash([])复位,否则坐标轴、柱体都会带着虚线(与合成模式同款「全局状态污染」陷阱); - 刻度文字右对齐:
textAlign='right'让所有刻度值右缘对齐在 y 轴左侧同一列——对齐是刻度文字的基本礼仪; - 垂直位置
top + plotH - plotH*i/4:i=0 在底边(0 刻度),i=4 在顶边(maxY)——网格线 = 数据域的等分投影。
三、坐标轴:两条线
this.ctx.strokeStyle = '#9CA3AF';
this.ctx.lineWidth = 1;
this.ctx.beginPath();
this.ctx.moveTo(left, top);
this.ctx.lineTo(left, h - bottom); // y 轴(左边界)
this.ctx.lineTo(w - right, h - bottom); // x 轴(底边界)
this.ctx.stroke();
一条路径连画两段:y 轴从绘图区顶到底,x 轴从左到右,交点即坐标系原点。坐标轴颜色比网格深一档(#9CA3AF vs #E5E7EB),层次分明。
四、渐变柱体:createLinearGradient
const slot = plotW / BAR_DATA.length; // 每根柱的槽位宽
const barW = slot * 0.55; // 柱体宽 = 槽位的 55%
for (let i = 0; i < BAR_DATA.length; i++) {
const b = BAR_DATA[i];
const x = left + slot * i + (slot - barW) / 2; // 柱体 x(槽内居中)
const bh = plotH * b.value / maxY; // 柱高
const y = h - bottom - bh; // 柱顶 y
const grad = this.ctx.createLinearGradient(0, y, 0, h - bottom);
grad.addColorStop(0, this.barActive === i ? '#2563EB' : '#3B82F6');
grad.addColorStop(1, '#93C5FD');
this.ctx.fillStyle = grad;
this.ctx.fillRect(x, y, barW, bh);
...
}
逐项拆解:
- 槽位 vs 柱宽:
slot = plotW / n是每根柱的领地,柱体只占 55%,留出 45% 空隙——柱间空隙是柱状图的识别特征,满宽相连就成直方图了; - 柱体居中:
x = left + slot*i + (slot - barW)/2,每根柱在自己的槽位内水平居中; - 渐变方向:
createLinearGradient(0, y, 0, h-bottom)—— 从柱顶 (y) 到柱底,垂直渐变;深色顶 → 浅色底,像光照从上方打下,柱体有了体积感; - 高亮换深色:
barActive === i时顶部色从#3B82F6换#2563EB——高亮 = 渐变参数变化,不额外绘制任何元素,改动最小。
五、数值标签与 x 轴名称
// 数值标签(柱顶上方)
this.ctx.fillStyle = this.barActive === i ? '#2563EB' : '#6B7280';
this.ctx.textBaseline = 'bottom';
this.ctx.fillText(String(b.value), x + barW / 2, y - 4);
// X 轴名称
this.ctx.fillStyle = '#6B7280';
this.ctx.textBaseline = 'top';
this.ctx.fillText(b.name, x + barW / 2, h - bottom + 6);
baseline 语义:数值标签 textBaseline='bottom' + y-4 —— 文字底边在柱顶上方 4 像素,柱顶再高也不会压字;x 轴名称 'top' + h-bottom+6 —— 文字顶边在轴下 6 像素。「底对齐」和「顶对齐」让两组文字各自贴着锚点生长,不会互相重叠。
六、触摸高亮:x 坐标 → 槽位下标
const idx = Math.floor((t.x - left) / slot);
if (idx >= 0 && idx < BAR_DATA.length) {
this.barActive = idx;
this.redrawCurrent();
}
命中检测 = 绘制映射的逆运算:绘制时 x = left + slot*i + (slot-barW)/2,命中时 i = floor((x - left)/slot)——只关心「点在哪个槽位」,不关心柱体具体宽度,容错更友好(点柱间空隙也算相邻柱)。
为什么用 Down 不用 Move:图表交互是「点选」,不需要连续跟踪;Down 触发一次即可,避免 Move 高频重绘。
七、绘制状态管理:为什么 fillStyle 每次都要设置
柱状图每根柱都设置 strokeStyle / fillStyle / font / textAlign / textBaseline,看起来「啰嗦」,但这是 Canvas 绘制的正确姿势——上下文状态是持久的。
7.1 状态残留的三个陷阱
- fillStyle 残留:第一根柱用了红色,第二根柱忘记设置 fillStyle,就会继承红色——颜色错乱且难排查;
- textBaseline 残留:y 轴刻度用了
middle,画 x 轴标签时忘记改回top,标签的垂直位置整体偏移; - setLineDash 残留:虚线网格后忘记
setLineDash([]),坐标轴和柱体全部变成虚线。
这些 bug 的共同特征是「上一次绘制污染了这一次」——Canvas 上下文保存了所有绘制状态,任何一次设置都会影响后续所有绘制,直到被显式覆盖。
7.2 防御策略:绘制函数开头「重置已知状态」
专业的做法是在每个绘制函数(drawBar/drawLine/drawPie)开头显式设置本函数需要的全部状态:
private drawBar(): void {
this.ctx.strokeStyle = '#9CA3AF';
this.ctx.lineWidth = 1;
this.ctx.fillStyle = '#000000';
this.ctx.font = '11vp sans-serif';
this.ctx.textAlign = 'right';
this.ctx.textBaseline = 'middle';
// ... 后续绘制
}
这样即使上一个函数留下了奇怪的设置,本函数开头就把状态「拉回已知基线」。虽然多写几行,但换来的是每个绘制函数独立正确——三张图的绘制互不干扰,正是「单画布三投影」架构能成立的前提。
7.3 与「组件样式」的对比
ArkUI 组件的样式(fontSize/fontColor)是声明式的:每次 build 时按声明重新应用,不担心残留。Canvas 的绘制状态是命令式的:设置一次持续生效。这个差异是「声明式 UI vs 命令式绘制」的核心区别之一,也是新手最容易栽跟头的地方——记住:Canvas 没有「自动复位」,一切状态都要自己管理。
八、深入:柱状图的「数据语义」与绘制细节
8.1 柱状图 vs 直方图:一字之差,两种图
很多人混淆柱状图(bar chart)和直方图(histogram):
| 维度 | 柱状图 | 直方图 |
|---|---|---|
| 数据 | 分类变量(星期、城市) | 连续变量的分段统计 |
| x 轴 | 分类名,无顺序意义 | 数值区间,有顺序 |
| 柱间 | 必须有空隙(本应用 45%) | 柱间无空隙(连续) |
| 用途 | 对比大小 | 观察分布形态 |
本应用的「柱间留 45% 空隙」正是柱状图的识别特征——如果去掉空隙,就变成直方图的观感了。理解这个区别,你的图表才不会「画错类型」。
8.2 为什么数值标签放柱顶上方而不是柱内
有经验的图表设计者会纠结「数值放柱顶上方还是柱内」:
- 上方(本应用):文字不被柱体遮挡、可读性最好,代价是柱顶与下一个网格线之间的空间可能不够(超高柱子时标签会顶出绘图区);
- 柱内:空间利用紧凑,但文字与柱体颜色需要对比设计,浅色柱放深字、深色柱放白字。
本应用选「上方 + 底部对齐」——对 7 根柱子的常规数据,柱顶上方空间足够。真实产品做动态数据(柱子高度随时变化)时,需要给顶部留更多余量或切换「柱内」策略。
8.3 渐变的「视觉心理」
柱体的垂直渐变(深蓝顶 → 浅蓝底)模拟了「光源从上方照射」的物理直觉:物体顶部受光强、底部受光弱。虽然 Canvas 里的渐变纯属装饰(柱子是二维矩形),但人的视觉系统会自动把「上深下浅」解读为「有体积的立体感」。这就是为什么渐变能显著提升图表「专业感」——它利用了视觉系统的物理先验,与真实 3D 渲染中的明暗处理同源。
七、文章小结
本节实现了柱状图:网格(setLineDash 虚线 + 刻度文字右对齐)→ 坐标轴(两段线)→ 渐变柱体(槽位 55% 宽 + 垂直渐变 + 高亮换色)→ 标签(bottom/top 双 baseline)→ 触摸高亮(逆映射命中)。核心技巧是「全局状态(setLineDash/fillStyle/textBaseline)用后即复位」与「绘制与命中互为逆运算」。
下一节实现折线图:面积渐变、平滑连线与 setInterval 驱动的绘制动画。
八、补充:FAQ
Q1:为什么柱宽是槽位的 55% 而不是固定像素?
槽位宽度随画布宽度自适应,固定 55% 比例保证任何屏幕下「柱间空隙 ≈ 柱宽 × 0.8」,观感恒定。固定像素在窄屏会挤爆、宽屏会稀疏。
Q2:渐变每帧都 createLinearGradient,浪费吗?
柱状图一帧只创建 7 个渐变对象,成本可忽略。渐变对象本质是颜色区间描述,创建开销远小于像素填充本身。
Q3:高亮为什么不用加边框/变亮?
换深色顶 + 数值标签同步换色,是改动最小的高亮方案;且触摸松手后依然保留(直到切 tab),符合「选中查看」的语义。想要「点击消失」只需在 Up 时重置 barActive=-1。
九、补充阅读:文本绘制的完整控制
柱状图用了 fillText + textAlign + textBaseline,这三个 API 组合起来能精确控制文字位置,是图表绘制的「隐形基础设施」,值得完整展开。
9.1 textAlign:水平对齐
| 取值 | 效果 | 图表里的用途 |
|---|---|---|
| left | 文字左缘对齐锚点 | x 轴标签左对齐 |
| center | 文字中心对齐锚点 | 数值标签居中于柱顶 |
| right | 文字右缘对齐锚点 | y 轴刻度右对齐(本应用) |
本应用的 y 轴刻度用 right:所有刻度值的右缘对齐在 y 轴左侧同一列,视觉整齐。如果用 left,不同位数的数字(40、200、320)左缘对齐后右缘参差,很乱。
9.2 textBaseline:垂直对齐
| 取值 | 效果 | 图表里的用途 |
|---|---|---|
| top | 文字顶缘对齐锚点 | x 轴名称(锚点在轴下方) |
| middle | 文字中线对齐锚点 | y 轴刻度(锚点在网格线上) |
| bottom | 文字底缘对齐锚点 | 数值标签(锚点在柱顶上方) |
9.3 两轴文字的「锚点哲学」
- y 轴刻度:
right + middle——右缘贴轴、垂直居中于网格线,读数时视线水平扫过; - x 轴名称:
center + top——水平居中于柱体、顶缘贴轴下缘,不与刻度重叠; - 数值标签:
center + bottom——水平居中于柱顶、底缘悬在柱顶上方 4vp,柱再高也不压字。
所有文字都围绕一个「锚点」生长——对齐方式决定了文字相对锚点往哪边伸展。想清楚锚点在哪,文字位置就不会飘。
十、补充阅读:fillRect 与路径填充的取舍
本应用画柱子用 fillRect(x, y, barW, bh)——一个矩形。但如果你要画「圆角柱顶」或「带阴影的柱体」,就需要换成路径:
this.ctx.beginPath();
// 圆角矩形的四条边 + 四个圆弧(或用 roundRect 简写)
this.ctx.moveTo(x, y + r);
this.ctx.arcTo(x, y, x + barW, y, r); // 左上圆角
this.ctx.arcTo(x + barW, y, x + barW, y + bh, r); // 右上圆角
this.ctx.lineTo(x + barW, y + bh);
this.ctx.lineTo(x, y + bh);
this.ctx.closePath();
this.ctx.fill();
对比:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| fillRect | 一行代码、快 | 只能直角 | 基础柱状图 |
| 路径 + arcTo | 任意形状 | 代码多 | 圆角柱、异形柱 |
| roundRect API | 简洁的圆角矩形 | 部分版本支持差异 | 圆角柱 |
本应用选 fillRect 聚焦核心;真实产品做「圆角柱顶」时,roundRect 或 arcTo 路径是常见升级。从 fillRect 到路径,是绘制自由度的一次跃迁——路径可以表达任意闭合形状,矩形只是它的特例。
十一、动手练习
- 柱宽实验:把
barW = slot * 0.55依次改成 0.3 / 0.8 / 1.0,观察柱间空隙变化,找到你最喜欢的比例; - 网格虚实实验:把
setLineDash([4, 4])改成[1, 4](细虚线)和[](实线),对比网格对柱体的干扰程度; - 渐变方向实验:把渐变从「垂直」改成「水平」
createLinearGradient(x, 0, x+barW, 0),观察柱体从「有高度感」变成「有宽度感」; - 命中容差实验:点柱间空隙,观察当前代码「命中相邻柱」的行为,再思考若要「空隙不命中」需要怎么改(比较 x 与柱体左右边界);
- 刻度算法实验:把 niceMax 的 50 改成 100 / 10,观察 y 轴刻度值的变化,理解「步长」对刻度可读性的影响。
十二、本篇知识点串联
柱状图是三种图表里「绘制管线最完整」的一张:它同时用到了路径(网格线、坐标轴)、矩形填充(柱体)、渐变(柱体光影)、文本(刻度与标签)、虚线(网格)、触摸命中(槽位除法)——几乎覆盖了 CanvasRenderingContext2D 的基础 API 全谱。
| API | 本节用途 | 学到什么 |
|---|---|---|
| setLineDash | 虚线网格 | 全局状态,用完复位 |
| moveTo/lineTo/stroke | 坐标轴 | 多段线路径 |
| createLinearGradient | 渐变柱体 | 装饰性光影 |
| fillRect | 柱体 | 最快矩形填充 |
| fillText + align/baseline | 刻度与标签 | 锚点定位 |
| floor 除法 | 命中检测 | 绘制逆运算 |
下一节的折线图会在此基础上引入路径的多点连接(lineTo 循环)与动画驱动(setInterval),柱子是「离散元素」,折线是「连续元素」,绘制思维将从「逐个画」升级为「连成线」。
折线图实战:平滑曲线、面积渐变与绘制动画
一、目标
折线图要有「高级感」需要三件套:面积渐变(线下的色带)、圆角折线、数据点;再加一个「从 0 画到 100%」的绘制动画——线像被画出来一样生长,体验远超静态图。
1.1 折线图与柱状图的定位差异
柱状图适合「离散分类的对比」,折线图适合「连续趋势的观察」。选哪种图的决策依据:
| 场景 | 推荐图表 | 原因 |
|---|---|---|
| 各城市销量对比 | 柱状图 | 分类无顺序,柱间空隙表达独立性 |
| 一周步数变化 | 折线图 | 时间连续,趋势是核心信息 |
| 时间分配占比 | 饼图 | 构成关系,占比是核心信息 |
折线图的「趋势」价值在于:读者一眼看到「上升/下降/波动/峰值」——这是柱状图难以传达的。所以折线图的绘制重点不是「每根柱多高」,而是「整条线的形态」。
1.2 折线图的「高级感」从哪来
同样的数据,为什么有的折线图看起来专业、有的像「小学生作业」?差异通常在三个细节:
- 线下有渐变面积:纯线条 + 渐变面积 = 现代图表标配,面积让「线的领地」有视觉重量;
- 数据点与线分离:白底描边点让线「穿过」点而不是「压住」点,层次分明;
- 入场动画:静态图 vs 生长动画,动画让读者「看着数据被画出来」,记忆点完全不同。
本节按「面积 → 折线 → 点 → 动画」的顺序实现,每个细节都有对应的 Canvas API 与设计理由。
二、数据点映射:index → 像素坐标
const pts = LINE_DATA.map((v: number, i: number) => ({
x: left + plotW * i / (LINE_DATA.length - 1),
y: h - bottom - plotH * v / maxY
}));
x 轴是等距下标映射:7 个点均分 plot 区宽度,i / (n-1) 让第 0 个点贴 y 轴、最后一点贴右缘——等距下标是折线图 x 轴的默认模型(x 轴无真实刻度时)。
三、面积渐变:线下的「色带」
const grad = this.ctx.createLinearGradient(0, top, 0, h - bottom);
grad.addColorStop(0, 'rgba(59, 130, 246, 0.35)');
grad.addColorStop(1, 'rgba(59, 130, 246, 0.02)');
this.ctx.fillStyle = grad;
this.ctx.beginPath();
this.ctx.moveTo(pts[0].x, h - bottom); // 左下角
for (let i = 0; i <= n; i++) {
this.ctx.lineTo(pts[i].x, pts[i].y); // 沿折线爬升
}
// …(动画插值点)
this.ctx.lineTo(pts[n].x, h - bottom); // 回到底部
this.ctx.closePath();
this.ctx.fill();
路径策略:从「折线起点正下方(底边)」出发 → 沿折线到终点 → 垂直落回底边 → 闭合 → 填充。这个闭合多边形就是「线下区域」,用垂直渐变填充:顶部蓝 35% 透明度、底部蓝 2% 透明度——越往下越淡,像雾一样融入背景,这是现代图表标配的「呼吸感」。
注意 addColorStop 的 rgba:渐变不仅支持 hex,也支持 rgba() 带透明——半透明色带是面积图的核心。
四、绘制动画:setInterval 逐帧生长
4.1 进度状态与计时器
private lineProgress: number = 1; // 0 → 1 动画进度
private lineTimer: number = -1;
private startLineAnim(): void {
if (this.lineTimer !== -1) {
return; // 防重入
}
this.lineProgress = 0;
this.lineTimer = setInterval(() => {
this.lineProgress += 0.04;
if (this.lineProgress >= 1) {
this.lineProgress = 1;
clearInterval(this.lineTimer);
this.lineTimer = -1;
}
this.redrawCurrent();
}, 30);
}
动画 = 计时器 + 进度 + 重绘:每 30ms 进度 +0.04(约 0.75 秒画完),每帧调用 redrawCurrent() 全量重绘,画布上「线长了一点」。防重入守卫(timer !== -1 直接 return)防止切 tab 快速连点开启多个计时器;结束后置 -1 让下次可再触发。
4.2 进度 → 可见点数的换算
const n = Math.max(1, Math.floor(this.lineProgress * (pts.length - 1)));
const frac = this.lineProgress * (pts.length - 1) - n;
n:当前已「完整画出」的段数(点下标);frac:当前段的进度小数(0~1),用于插值出「半截线段」。
核心思想:动画不是一帧帧复制,而是用进度重算几何——每帧只画「前 n 个完整点 + 第 n+1 点的插值位置」。插值点:
const px = pts[n].x + (pts[n + 1].x - pts[n].x) * frac;
const py = pts[n].y + (pts[n + 1].y - pts[n].y) * frac;
线性插值让折线「尖端」沿段滑行,动画顺滑自然。
4.3 折线与面积共用同一套进度
面积路径与折线路径使用完全相同的 n 与 frac——面积跟着折线一起生长,两者严格同步,不会出现「面积画完线还没到」的错位。共享动画状态是组合绘制的纪律。
五、数据点与高亮气泡
this.ctx.beginPath();
this.ctx.arc(pts[i].x, pts[i].y, this.lineActive === i ? 5 : 3, 0, Math.PI * 2);
this.ctx.fillStyle = this.lineActive === i ? '#EF4444' : '#FFFFFF';
this.ctx.fill();
this.ctx.strokeStyle = '#3B82F6';
this.ctx.lineWidth = 2;
this.ctx.stroke();
白底描边点:白填充 + 蓝描边的圆点——白色把折线「隔断」,视觉上是「线穿过点」而非「点坐在线上」,这是折线图数据点的经典画法。
触摸最近点:idx = Math.round((t.x - left) / slot),触摸 x 映射到最近的点下标(round 而非 floor,取「最近」而非「左侧」);命中后:
- 该点放大变红(半径 3→5,填充红);
- 点上方画数值气泡:
fillText(value, x, y-10)深色文字——数值可读是折线图触摸的价值。
六、动画的边界处理
切 tab 时清理:
private switchTab(idx: number): void {
this.tab = idx;
this.barActive = -1;
this.pieActive = -1;
this.lineActive = -1;
this.redrawCurrent();
if (idx === 1) {
this.startLineAnim(); // 切到折线图才启动动画
}
}
以及 aboutToDisappear 里兜底清理计时器——页面销毁必须释放 setInterval,否则页面退出后计时器仍在跑(内存泄漏 + 无效重绘)。
防重入的另一面:动画进行中切走再切回,lineTimer 可能还活着——switchTab 里 startLineAnim() 的守卫会跳过重复启动,等旧动画自然结束。极端情况下可改为「切换即强制停止并重启」,本应用从简。
七、文章小结
本节实现了折线图:等距下标映射 → 面积渐变(rgba 色带 + 闭合路径)→ 绘制动画(setInterval + 进度 → 段数/插值)→ 白底描边点 + 触摸数值气泡。核心技巧是「动画 = 进度重算几何」——每帧按进度重绘,线与面积共享同一进度,既简单又天然同步。
7.1 本篇新增 API 速查
| API | 用途 | 备注 |
|---|---|---|
| setInterval / clearInterval | 动画驱动 | 防重入 + 完成后清理 |
| createLinearGradient + addColorStop(rgba) | 面积色带 | 支持半透明色 |
| arc(x, y, r, 0, 2π) | 数据点圆 | 白底 + 描边 |
| Math.floor / Math.round | 段数/最近点 | 命中检测 |
| aboutToDisappear | 页面销毁清理 | 计时器兜底释放 |
7.2 折线图动画的设计取舍回顾
动画实现中有三个决策值得复盘,它们共同决定了动画的「顺滑与健壮」:
- 用进度重算几何,而非逐帧缓存:不保存每一帧的画面,而是每帧从进度推导「画到哪」——画面永远自洽,切走切回都不出错;
- 线与面积共享进度:两套路径使用完全相同的 n 和 frac,视觉同步,不会出现「面积画完线没到」;
- 进度是纯数据:lineProgress 只是 0~1 的数字,不依赖 Canvas 状态——这使它天然可测试、可序列化、可跨函数传递。
下一节实现饼图:环形扇区、百分比标注、图例与触摸外弹。
八、补充:FAQ
Q1:为什么动画用 setInterval 不用 requestAnimationFrame?
setInterval(30ms) 足够平滑且实现简单、可精确控制进度步长,适合「一次性生长动画」。requestAnimationFrame 更适合持续帧循环(第 5 个应用会用到类似思路),按场景取舍。
Q2:动画中途切 tab,线会画一半吗?
切回折线图时 startLineAnim 有防重入守卫:若旧计时器还在跑则跳过,线从旧进度继续;若已结束(timer=-1)则重新从 0 播放。行为可预期,无「半截线」残留——因为每次重绘都从进度重算。
Q3:进度是 0~1,为什么 n 用 Math.max(1, …)?
进度极小(第一帧)时 floor 得 0,面积路径 lineTo(pts[n]) 会画到 pts[0]——其实 0 也合法;max(1,…) 是防御性写法,保证至少画出第一段,避免极端进度下路径退化成点。
九、深入:动画系统的三种实现方式对比
折线图用了 setInterval 驱动动画,但这不是唯一选择。理解三种方式的差异,你才能在正确场景选对工具:
| 方式 | 原理 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| setInterval | 固定间隔回调 | 简单、可控、精确 | 间隔不随刷新率自适应 | 一次性动画、低帧率需求 |
| setTimeout 链 | 每次回调里再设下一个 | 可动态调整间隔 | 写法繁琐 | 变速动画 |
| requestAnimationFrame | 与屏幕刷新同步 | 平滑、省电、自动暂停 | 回调频率不可控 | 持续帧循环(第 5 应用) |
本应用为什么选 setInterval:折线生长动画是一次性的(约 0.75 秒),30ms 间隔已足够平滑;setInterval 的进度步进(+0.04)可精确控制动画时长,实现最简单。requestAnimationFrame 的优势(与刷新率同步)在「持续动画」场景才明显——第 5 个应用(粒子特效)会切换到它。
9.1 setInterval 的「防重入」为什么必要
如果切 tab 时没有守卫,用户快速「切走 → 切回 → 切走 → 切回」可能启动多个计时器:
- 每个计时器都在改 lineProgress、都在调用 redrawCurrent();
- 进度被多个计时器同时累加,动画速度变快(3 个计时器 = 3 倍速);
- 更糟的是 clearInterval 只清掉其中一个,其余计时器永远运行(泄漏)。
守卫 if (this.lineTimer !== -1) return 保证同一时刻只有一个计时器在跑。任何「全局定时资源」都需要类似的防重入——这是计时器使用的第一纪律。
十、进阶:让折线图更「专业」的三个打磨点
10.1 平滑曲线:从折线到贝塞尔
如果数据点之间的斜率变化剧烈(相邻点落差大),直线连接会出现「尖锐折角」。用前面 1-3 篇学过的中点贝塞尔可以让折线平滑:
this.ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i + 1].x) / 2;
const my = (pts[i].y + pts[i + 1].y) / 2;
this.ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
this.ctx.lineTo(pts[last].x, pts[last].y);
注意取舍:折线图平滑会「抹掉数据点的存在感」,如果数据的波动本身就是信息(如监测数据),直线连接反而更诚实。是否平滑取决于「读者需要看到什么」。
10.2 数据点的大小与描边
本应用数据点是「白底 + 蓝描边」的经典画法。如果数据点密集(几十上百个),可以:
- 缩小半径(3 → 2);
- 只在「选中点」画完整圆,其余点画小圆点;
- 甚至不画数据点,只画折线(面积图风格)。
数据点密度决定画法——密集时点会互相遮挡变成「实心带」,此时去掉点或缩小点更清晰。
10.3 网格的透明度分级
专业图表的网格线往往分两级:主网格(实线/深色,对齐刻度)和次网格(虚线/浅色,细分刻度)。本应用只有 4 等分主网格;若要更精细:
// 主网格(每 1/4)
strokeStyle = '#E5E7EB';
// 次网格(每 1/8,更浅)
strokeStyle = '#F3F4F6';
两级网格让「粗读」和「细读」都有参照,是仪表盘类图表的标配。
十一、动画的完整生命周期管理
把动画从「启动」到「销毁」的完整生命周期整理成清单,作为所有 Canvas 动画的通用模板:
启动:switchTab 切到折线图 → startLineAnim()
运行:setInterval 回调 → 进度累加 → redrawCurrent()
完成:进度到达 1 → clearInterval → lineTimer = -1
切走:switchTab 切到其他图 → (动画继续跑,但 drawLine 不再被调用)→ 自然结束
销毁:aboutToDisappear → clearInterval 兜底
核心规则:
- 启动必须有守卫(防重入);
- 完成必须清资源(clearInterval + 置 -1);
- 销毁必须兜底(aboutToDisappear 里再清一次——页面退出时计时器可能还在跑,不清理会泄漏);
- 状态与绘制解耦:进度是数据,绘制函数每帧读取进度重新投影——动画中途任何时刻的状态都是「当前进度的正确画面」。
这条模板在应用 5(粒子特效)会升级为「帧循环 + 对象池」,但「生命周期四阶段」的骨架完全一致。
十二、折线图与柱状图的绘制思维对比
同为图表,折线图和柱状图的绘制思维有本质区别,理解这个区别能帮你更快上手更多图表:
| 维度 | 柱状图 | 折线图 |
|---|---|---|
| 元素类型 | 离散(每根柱独立) | 连续(整条线) |
| 绘制单位 | 逐根柱 fillRect | 一条多段路径 lineTo |
| 状态依赖 | 柱间无依赖 | 点与点有顺序关系 |
| 动画方式 | 无(或逐柱生长) | 进度截断 + 插值 |
| 命中检测 | 槽位除法(一维) | 最近点(一维距离) |
柱状图是「逐个画」,折线图是「连成线」——前者把数据看成独立个体,后者把数据看成连续序列。图表的形态决定了你的绘制思维:离散数据逐个画,连续数据连起来画。
十三、动手练习
- 动画速度实验:把
this.lineProgress += 0.04改成 0.02 / 0.08,观察动画时长变化,理解「进度步长 = 动画速度」; - 面积透明度实验:把
rgba(59, 130, 246, 0.35)改成 0.6 / 0.05,观察面积色带的浓淡对折线可读性的影响; - 最近点算法实验:把命中检测的
Math.round改成Math.floor,观察触摸点下方与左侧数据点的行为差异; - 数据点样式实验:把数据点从「白底描边」改成「纯色实心」,观察线穿过点 vs 点盖住线的视觉差异;
- 无动画实验:把 lineProgress 直接置 1(跳过动画),对比静态图与动画图的观感,体会「入场动画」的价值。
饼图实战:环形扇区、百分比标注与触摸外弹
一、目标
饼图展示「整体构成」。本应用做成**环形(donut)**而非实心——环带让百分比标注有落点、视觉更现代。要素:
- 环形扇区(外弧 + 内弧反向闭合);
- 扇区中间环带上的百分比标注;
- 中心标题;
- ArkUI 图例(复用数据源);
- 触摸扇区外弹(explode)。
1.1 为什么饼图是「构成关系」的图
三种图表各回答一个问题:
| 图表 | 回答的问题 | 读者关注点 |
|---|---|---|
| 柱状图 | 谁大谁小? | 柱高对比 |
| 折线图 | 怎么变化? | 趋势方向 |
| 饼图 | 各占多少? | 占比与整体 |
饼图的特殊性在于「整体 = 100%」的隐含前提:所有扇区之和就是整体,任何一个扇区的变化都会影响其他扇区的大小(因为占比是相对的)。这种「此消彼长」的语义让饼图在交互上也有特殊性——选中的扇区外弹,其余扇区位置不变但「视觉比例感」变化。
1.2 环形 vs 实心:为什么选 donut
实心饼图(pie)和环形饼图(donut)的对比:
| 维度 | 实心 | 环形(本应用) |
|---|---|---|
| 百分比标注 | 放扇区内,小扇区放不下 | 放环带,空间充足 |
| 中心区域 | 无用途 | 放标题/总数值 |
| 小扇区可读性 | 差(角度小、面积小) | 较好(环带等宽) |
| 现代感 | 传统 | 现代仪表盘主流 |
环形不是「挖了个洞」那么简单——那个洞是信息空间。这是图表设计里「负空间利用」的经典案例:空出来的区域不是浪费,是给信息腾位置。
二、扇区几何:角度是核心
const total = this.pieTotal(); // 数值总和
let angle = -Math.PI / 2; // 从 12 点方向开始
for (let i = 0; i < PIE_DATA.length; i++) {
const p = PIE_DATA[i];
const span = p.value / total * Math.PI * 2; // 本扇区角度跨度
const mid = angle + span / 2; // 扇区中分线角度
// …绘制…
angle += span; // 游标推进
}
扇形建模三要素:
- 起点
-π/2(12 点方向):Canvas 的arc以 3 点方向为 0°,饼图惯例从顶部开始,所以起点偏移 -90°; - 跨度与数值成正比:
value/total × 2π——占比越大弧越长,这就是「饼」; - 游标累加:
angle += span让每个扇区紧挨前一个,形成完整的环。
三、环形扇区:双弧反向闭合
this.ctx.beginPath();
this.ctx.arc(ox, oy, r, angle, angle + span); // 外弧(顺时针)
this.ctx.arc(ox, oy, rIn, angle + span, angle, true); // 内弧(逆时针返回)
this.ctx.closePath();
this.ctx.fillStyle = p.color;
this.ctx.fill();
关键 API:arc(x, y, r, startAngle, endAngle, counterclockwise)——第六个参数 true 表示逆时针。画环形的经典套路:
- 外弧从 angle 顺时针到 angle+span;
- 内弧从 angle+span 逆时针回到 angle;
- closePath 补一条连线,闭合出「月牙形」环带区域。
rIn = r * 0.62:内半径是外半径的 62%,环带宽度适中,既能放百分比文字,又保留 donut 的现代感。
四、百分比标注:放在环带中线上
const pct = Math.round(p.value / total * 100);
const lx = ox + Math.cos(mid) * (r + rIn) / 2; // 环带中线位置
const ly = oy + Math.sin(mid) * (r + rIn) / 2;
this.ctx.fillStyle = '#FFFFFF';
this.ctx.fillText(`${pct}%`, lx, ly);
极坐标 → 直角坐标:(r+rIn)/2 是环带半径中线,(cos(mid), sin(mid)) 是扇区中分线方向——百分比文字永远落在自己扇区的环带正中间,不会漂到隔壁扇区。
白字:扇区是深饱和色,白色文字对比度最高。文字内容取整到整数百分比(Math.round),避免 34.999…% 的怪数字。
五、中心标题
this.ctx.fillStyle = '#1F2937';
this.ctx.font = '16vp sans-serif';
this.ctx.fillText('时间分配', cx, cy - 8);
this.ctx.font = '11vp sans-serif';
this.ctx.fillStyle = '#9CA3AF';
this.ctx.fillText(`${PIE_DATA.length} 项`, cx, cy + 12);
Donut 中空区不是浪费:放标题 + 副信息,环形图的中空区是天然的「信息锚点」。两行文字一深一浅、字号一大一小,主次分明。
六、图例:复用数据的 ArkUI 行
Row({ space: 14 }) {
ForEach(PIE_DATA, (p: PieItem) => {
Row({ space: 4 }) {
Row().width(10).height(10).borderRadius(5).backgroundColor(p.color) // 色点
Text(`${p.name} ${p.value}%`).fontSize(11).fontColor('#6B7280')
}
}, (p: PieItem) => p.name)
}.width('100%').justifyContent(FlexAlign.Center).margin({ top: 10 })
图例不画在 Canvas 里,用 ArkUI 组件渲染——色点(10×10 圆)+ 名称 + 百分比。为什么?图例是「文字排版」,组件化渲染更易布局、支持换行、可无障碍朗读;Canvas 里的文字要手动排版。**「画图形、组件排文字」**是图表混合架构的最佳实践。
七、触摸外弹:命中检测 + 偏移重绘
7.1 命中检测:向量 → 角度 → 扇区
const cx = this.canvasW / 2;
const cy = this.canvasH / 2 + 10;
const dx = t.x - cx;
const dy = t.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > this.pieOuter()) {
return; // 点在环外,不响应
}
let angle = Math.atan2(dy, dx) + Math.PI / 2; // 转成与绘制一致的 0 起
if (angle < 0) {
angle += Math.PI * 2;
}
const total = this.pieTotal();
let acc = 0;
for (let i = 0; i < PIE_DATA.length; i++) {
const span = PIE_DATA[i].value / total * Math.PI * 2;
if (angle >= acc && angle < acc + span) {
this.pieActive = i;
this.redrawCurrent();
return;
}
acc += span;
}
命中检测三部曲:
- 距离过滤:点到圆心距离超外半径直接忽略——先粗筛(圆内),再做细判(角度);
- 角度换算:
atan2(dy, dx)给出 -π~π 的屏幕角度(0° 在 3 点方向),+π/2对齐绘制的 -π/2 起点,负数加 2π 归一到 [0, 2π)——命中角度与绘制角度必须同坐标系,这是逆运算的第一纪律; - 区间遍历:角度落在哪个扇区的 [acc, acc+span) 区间内,即命中哪个扇区。
7.2 外弹:偏移圆心重画
const offset = this.pieActive === i ? 10 : 0;
const ox = cx + Math.cos(mid) * offset; // 沿中分线方向外移
const oy = cy + Math.sin(mid) * offset;
// 之后所有 arc 用 (ox, oy) 而不是 (cx, cy)
外弹原理:把扇区的圆心沿「中分线方向」平移 10 像素,扇区整体「滑出」环体,与相邻扇区之间出现缝隙——视觉上像被抽出来。圆心可变 + 半径不变,几何上最简单的外弹实现。
命中容差:pieOuter() = r + 14,外弹后的扇区边缘最远到 r+10,多留 4 像素容差,手指点外弹边缘也能命中。
7.3 为什么外弹的视觉「自然」
外弹(explode)是饼图交互的经典效果,它的「自然感」来自几何上的一个巧妙设计:每个扇区沿自己的中分线方向移动。
- 中分线是扇区的「对称轴」,沿它移动 = 沿扇区自身的方向滑出;
- 所有扇区共用同一个圆心,外弹后每个扇区都「远离」公共圆心,缝隙均匀;
- 视觉上就像「把一块披萨从整张饼里抽出来」,符合物理直觉。
如果改成「所有扇区统一向上移」(沿固定方向),视觉上就会变成「整张饼被掀起来」,完全失去「选中某一角」的语义。交互效果的方向选择,要符合用户的心智模型。
7.4 触摸状态的完整生命周期
饼图的 pieActive 状态经历四个阶段:
初始:pieActive = -1(无选中)
触摸命中:pieActive = i → redraw(外弹)
再触摸其他扇区:pieActive = j → redraw(外弹切换)
切 tab:switchTab 里 pieActive = -1 → redraw(状态复位)
与柱状图的 barActive、折线图的 lineActive 完全同构——三张图共用同一个「状态 + 重绘」交互范式,只是命中检测的几何不同。这是「单画布三投影」架构在交互层的一致体现。
八、文章小结
本节实现了饼图:游标角度建模(-π/2 起点 + 累加跨度)→ 环形扇区(外弧 + 内弧反向闭合)→ 环带中线百分比 → 中心标题 → 组件化图例 → 触摸外弹(距离过滤 + 角度归一 + 圆心平移)。核心技巧是「绘制与命中共享同一套角度坐标系」与「外弹 = 圆心平移」。
下一节给出数据图表完整代码与运行效果,收尾本应用。
九、补充:FAQ
Q1:为什么不用实心饼图,中间挖空?
环形(donut)有三大优势:百分比文字有固定落点(环带)、中心可放标题、小扇区视觉占比更清晰。现代仪表盘/图表普遍采用 donut。
Q2:命中检测为什么先比距离再比角度?
角度命中只判断方向,不判断远近——圆外的点角度也可能落在扇区区间内,会把「点在空白处」误判为「命中扇区」。先距离后角度,两级过滤,严谨且省计算。
Q3:外弹的扇区会盖住相邻扇区吗?
不会。外弹是平移而非放大,扇区自身尺寸不变,只是沿中分线滑出;相邻扇区保持原位,缝隙由「各自远离公共圆心」自然形成。
十、深入:饼图的几何模型与角度约定
饼图是三张图里「几何最特殊」的一张——它不依赖 x/y 线性映射,而是依赖极坐标(半径 + 角度)。理解极坐标的约定,是读懂饼图代码的前提。
10.1 Canvas 的角度约定
Canvas 的 arc 与三角函数使用弧度制,且角度从 **3 点方向(正 x 轴)**开始、顺时针为正:
3 点 = 0 6 点 = π/2 9 点 = π 12 点 = -π/2(或 3π/2)
这与很多人的直觉(12 点 = 0°、顺时针)不同。饼图惯例从 12 点开始,所以绘制起点用 -Math.PI / 2(等价于 3π/2,即 12 点方向)。
10.2 为什么起点偏移 90° 而不是其他值
饼图从顶部(12 点)开始是设计惯例,原因有二:
- 视觉重心:第一扇区从顶部开始,顺着顺时针展开,读者「阅读」的顺序与钟表一致,符合直觉;
- 命中一致性:触摸检测用
atan2得到角度后要+π/2对齐绘制起点——如果绘制起点是 0°(3 点),命中换算也要相应改,两处约定必须始终一致。
核心纪律:绘制角度与命中角度必须用同一套坐标系——这是 3-4 篇反复强调的第一原则。
10.3 角度与弧度的换算
| 角度 | 弧度 | 说明 |
|---|---|---|
| 0° | 0 | 3 点方向 |
| 90° | π/2 | 6 点方向 |
| 180° | π | 9 点方向 |
| 270° | 3π/2(-π/2) | 12 点方向 |
| 360° | 2π | 回到起点 |
代码里 span = value/total * 2π 把占比换算成弧度跨度;mid = angle + span/2 找扇区中分线——所有的比例、角度、弧度换算都在数学上闭环,这也是饼图「绘制与命中互为逆运算」的根基。
十一、进阶:饼图的常见变体
理解了基础环形饼图,下面三个变体都是「在基础模型上加一点东西」,帮你看到模型的扩展性:
11.1 半圆环(仪表盘风格)
只需把总角度从 2π 改成 π(或 1.5π),起点从 -π/2 改成 0 或 π,就得到半圆仪表盘——第 4 个应用(时钟仪表盘)的进度环就是这种思路的延伸。
11.2 玫瑰图(南丁格尔图)
把每个扇区的半径与数值挂钩(而不是角度),得到玫瑰图——数值越大扇区越宽越长。绘制差异只在 r 的计算:
const r = minR + (value / maxValue) * (maxR - minR); // 半径随数值变化
11.3 多层环(同心环)
用多个半径画多个环,每个环代表一个维度——就是「旭日图」的雏形。数据模型升级为 Layer[],绘制时从外到内逐层画,命中时从外向内逐层判断。
这些变体都共享同一套「极坐标 + 角度游标」模型——掌握了基础模型,变体只是参数变化。
十二、动手练习
- 起点实验:把饼图起点从
-π/2改成0(3 点方向),观察所有扇区整体旋转 90°,并思考命中检测需要同步改什么; - 内半径实验:把
rIn = r * 0.62改成 0.3 / 0.85,观察环带从「粗环」到「细环」的变化,找到百分比文字可读性最好的宽度; - 外弹距离实验:把 offset 从 10 改成 4 / 18,观察外弹幅度的视觉差异与命中容差(pieOuter = r + 14)的匹配;
- 比例换算实验:把 PIE_DATA 的某个 value 改成 0,观察该扇区消失、其余扇区重新均分 360°——验证「占比 = value/total」的实时性;
- 极坐标练习:不写代码,算出「占 25%、起始角 -90°」的扇区中分线角度与百分比文字位置,验证你对极坐标的掌握。
十三、本篇知识点串联
饼图用到的 API 与前面两篇形成互补:
| API | 本节用途 | 前两篇对照 |
|---|---|---|
| arc + counterclockwise | 环形扇区双弧 | 折线图用 arc 画数据点 |
| atan2 | 触摸角度 | 柱状图用除法算槽位 |
| Math.cos / Math.sin | 极坐标→直角坐标 | 折线图用线性映射 |
| fillText | 百分比标注 | 柱状图刻度标签 |
三张图凑齐了 Canvas 绘图的「几何三件套」:线性映射(柱)、连续路径(线)、极坐标(饼)。下一节给出完整代码与运行效果,收尾数据图表应用。
十四、本篇核心结论一句话
饼图的本质是「把占比换算成角度,把角度换算成坐标」:数据域的一个百分比 → 极坐标的一个弧度 → 直角坐标的一个点。整个实现(绘制、标注、命中、外弹)都是在这条换算链上做文章:
- 绘制:占比 → 弧度(span)→ arc 双弧;
- 标注:中分线角度 → cos/sin → 环带坐标;
- 命中:触摸点 → atan2 → 角度 → 区间比对;
- 外弹:中分线角度 → cos/sin → 圆心平移。
「绘制与命中互为逆运算」在饼图里体现得最纯粹——因为极坐标的换算链是双向可逆的,正向是画,反向是查。掌握饼图,你就掌握了「图形交互 = 几何建模 + 双向换算」的完整范式,这也是后续时钟仪表盘(应用 4)圆形绘制的基础。
14.1 从饼图到仪表盘的思维迁移预告
饼图与外弹交互的这套「极坐标 + 角度」模型,与下一个应用(时钟仪表盘)只有一步之遥:
- 饼图的扇区角度 → 仪表盘的刻度角度(同一套角度累加);
- 饼图的外弹圆心平移 → 仪表盘的指针旋转(角度驱动的旋转绘制);
- 饼图的命中角度查询 → 仪表盘的当前时间 → 指针角度(反向换算)。
如果把饼图比作「静态的角度分配器」,仪表盘就是「动态的角度发生器」——前者由数据决定角度,后者由时间决定角度。数据模型从「记录」变成「计算」,是应用 4 的核心转变,敬请期待。
数据图表完整代码与运行效果
一、代码全景
完整源码见 Canvas_2d/3_数据图表/ChartCanvas.ets,结构:
import { promptAction } from '@kit.ArkUI';
interface BarItem { name: string; value: number; }
interface PieItem { name: string; value: number; color: string; }
const BAR_DATA: BarItem[] = [/* 7 天销量 */];
const LINE_DATA: number[] = [10, 26, 18, 42, 33, 55, 48];
const LINE_NAMES: string[] = ['周一', ..., '周日'];
const PIE_DATA: PieItem[] = [/* 学习/工作/运动/娱乐 */];
const TABS: string[] = ['📊 柱状图', '📈 折线图', '🍩 饼图'];
@Entry
@Component
struct ChartCanvas {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private canvasW: number = 0;
private canvasH: number = 0;
@State tab: number = 0; // 图表类型
private barActive: number = -1; // 柱高亮
private pieActive: number = -1; // 饼外弹
private lineActive: number = -1; // 线最近点
private lineProgress: number = 1; // 折线动画进度
private lineTimer: number = -1;
build() { /* 标题栏 + 类型切换 + 图表面板(画布 + 图例)+ 触摸 */ }
private switchTab(idx: number): void { /* 切类型,折线图启动动画 */ }
private redrawCurrent(): void { /* 按 tab 分发到三个绘制函数 */ }
private handleTouch(e: TouchEvent): void { /* 三种命中检测 */ }
private drawBar(): void { /* 网格/坐标轴/渐变柱/标签 */ }
private startLineAnim(): void { /* setInterval 驱动 */ }
private drawLine(): void { /* 面积渐变/折线/点/气泡 */ }
private pieTotal(): number { /* 数值和 */ }
private pieOuter(): number { /* 命中半径 */ }
private drawPie(): void { /* 环形扇区/百分比/中心标题 */ }
private niceMax(arr: number[]): number { /* 坐标轴上限 */ }
}
1.1 结构总览:数据 → 状态 → 绘制 → 交互
与前面两个应用一样,代码遵循「数据驱动」的组织方式,但图表应用多了一层「状态」:
| 区块 | 内容 | 职责 |
|---|---|---|
| 数据区 | BAR_DATA / LINE_DATA / PIE_DATA | 业务数据(唯一真相源) |
| 常量区 | TABS / 布局留白 | 配置集中地 |
| 状态区 | tab / barActive / lineProgress | 当前视图与交互状态 |
| 绘制区 | drawBar / drawLine / drawPie | 数据 → 画面 |
| 交互区 | switchTab / handleTouch | 用户操作 → 状态变化 |
| 工具区 | niceMax / pieTotal / pieOuter | 几何与刻度计算 |
六个区块各司其职,阅读代码时按「数据 → 状态 → 绘制 → 交互」的顺序理解,逻辑非常清晰。
二、关键代码逐段回放
2.1 三图分发(单画布多投影)
private redrawCurrent(): void {
if (this.canvasW === 0) {
return;
}
this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
if (this.tab === 0) {
this.drawBar();
} else if (this.tab === 1) {
this.drawLine();
} else {
this.drawPie();
}
}
2.2 柱状图渐变柱体
const grad = this.ctx.createLinearGradient(0, y, 0, h - bottom);
grad.addColorStop(0, this.barActive === i ? '#2563EB' : '#3B82F6');
grad.addColorStop(1, '#93C5FD');
this.ctx.fillStyle = grad;
this.ctx.fillRect(x, y, barW, bh);
2.3 折线动画进度 → 几何
this.lineProgress += 0.04;
if (this.lineProgress >= 1) {
this.lineProgress = 1;
clearInterval(this.lineTimer);
this.lineTimer = -1;
}
const n = Math.max(1, Math.floor(this.lineProgress * (pts.length - 1)));
const frac = this.lineProgress * (pts.length - 1) - n;
// 面积/折线/点 全部按 n 与 frac 重算
2.4 环形扇区 + 外弹
const offset = this.pieActive === i ? 10 : 0;
const ox = cx + Math.cos(mid) * offset;
const oy = cy + Math.sin(mid) * offset;
this.ctx.beginPath();
this.ctx.arc(ox, oy, r, angle, angle + span);
this.ctx.arc(ox, oy, rIn, angle + span, angle, true);
this.ctx.closePath();
this.ctx.fill();
三、运行效果
| 操作 | 效果 |
|---|---|
| 初始加载 | 柱状图:网格 + 渐变柱 + 数值标签,直接呈现 |
| 切「📈 折线图」 | 折线从 0 开始生长动画,约 0.75 秒画完 |
| 切「🍩 饼图」 | 环形扇区 + 百分比 + 图例展示 |
| 触摸柱体 | 该柱变深蓝高亮,数值标签同步变色 |
| 触摸折线点 | 点放大变红,上方出现数值气泡 |
| 触摸扇区 | 扇区沿中分线外弹 10vp,再点其他扇区切换 |
| 点「↻」 | 重绘当前图表;折线图则重新播放动画 |
边界验证:触摸饼图环外 → 无响应(距离过滤);触摸柱间空隙 → 命中相邻柱(槽位容错);动画中快速切换 tab → 无重复计时器(防重入守卫)。
四、可扩展方向
- 动态数据:setInterval 定期更新数据并重绘,形成实时监控图表(仪表盘场景);
- 多系列:柱状图支持 grouped/stacked,折线图支持多条线(数据模型加 series 字段);
- 坐标轴刻度算法:把 niceMax 升级成「任意数量级好看刻度」(1/2/5 步长);
- tooltip 跟随:Move 事件 + 最近点浮层(当前是 Down 点选)。
五、应用小结
数据图表完整落地了**「数据域 ↔ 像素域」映射**的方法论:
- 数据模型:三组静态数据,饼图数据内嵌颜色(单一真相源);
- 坐标系:四边留白 + niceMax 上限 + 线性映射;
- 三图投影:柱(网格/渐变)、线(面积/动画)、饼(环/角度),一套坐标系三种画法;
- 交互:绘制与命中互为逆运算,高亮/外弹 = 状态 + 重绘;
- 动画:setInterval + 进度重算几何,线与面积共享进度。
下一节开启第 4 个应用——动态时钟与仪表盘:圆形绘制、三角函数表针、定时走时与进度环。
六、FAQ 补充
Q:lineActive 高亮点为什么要在动画 n 之内才画气泡?
动画进行中,未画到的点(下标 > n)还没出现在画布上,若气泡照画会造成「气泡先于线出现」的穿帮。lineActive <= n 的守卫保证气泡只出现在已画区域。
七、代码运行检查清单
| 检查项 | 正确姿势 | 错误表现 |
|---|---|---|
| 绘制函数状态复位 | drawBar/drawLine/drawPie 开头重置样式 | 三图颜色/字体互相污染 |
| setLineDash 复位 | 网格画完 setLineDash([]) |
坐标轴柱体全变虚线 |
| 动画防重入 | startLineAnim 里 timer !== -1 守卫 |
快速切 tab 多计时器叠加 |
| 动画清理 | 完成后 clearInterval + 置 -1 | 计时器泄漏、3 倍速动画 |
| 页面销毁清理 | aboutToDisappear 里 clearInterval | 退出页面后计时器继续跑 |
| 命中坐标一致性 | 绘制与命中用同一套 left/slot | 点柱体无高亮、点空隙却高亮 |
| 饼图角度归一 | atan2 结果 +π/2 且负数 +2π | 命中扇区错位 90° |
| 数值标签防重叠 | 标签 baseline 与锚点匹配 | 文字压柱顶/错位 |
八、性能与内存小结
图表应用是「静态为主 + 短时动画」的轻量场景:
- 静态绘制:三张图每帧只有几十个绘制命令,微秒级;
- 动画期:折线生长约 0.75 秒、每 30ms 一帧,共 25 帧,CPU 占用可忽略;
- 内存:无位图、无长生命周期对象,唯一需要管理的资源是 lineTimer(已用防重入 + 兜底清理);
- 与后续应用的对照:图表的动画是「一次性、短时、进度驱动」,第 5 个应用的粒子动画是「持续、高频、对象驱动」——两种动画的资源管理策略完全不同,届时会展开。
九、动手练习:把图表「玩坏」
- 改数据结构:给 BAR_DATA 加两根柱子(8 个分类),观察槽位自适应、柱宽不变、x 轴标签自动排开——验证「数据驱动布局」;
- 改动画时长:把
+= 0.04改成+= 0.01,动画从 0.75 秒变成 3 秒,观察进度步长与动画时长的线性关系; - 改饼图数据:把 PIE_DATA 加一项「休息 10」,观察总占比仍为 100%、各扇区角度自动缩小——验证「占比 = value/total」;
- 交叉触发实验:柱状图高亮后切折线图再切回,观察 barActive 是否复位——理解 switchTab 里的状态重置;
- 双图表联动:给柱状图加「点击柱子后折线图高亮对应点」的联动,验证「状态 + 重绘」范式可以跨图表复用。
十、三张图的绘制管线总览
最后把三张图的完整绘制管线并排,作为应用 3 的收官总结:
柱状图:清屏 → 虚线网格 → 坐标轴 → 渐变柱体 → 数值标签 → x 轴名称
折线图:清屏 → 面积渐变(进度截断)→ 折线(进度截断)→ 数据点 → 气泡
饼图:清屏 → 环形扇区(外弹偏移)→ 百分比 → 中心标题 → 组件图例
三张图共享同一套「清屏 → 逐元素绘制」的骨架,差异只在元素类型与几何映射。这也再次印证本系列的主线:绘制应用的复杂度来自数据与几何,而非绘制机制本身——机制永远是「清屏 + 按数据重绘」。
十一、FAQ 扩展
Q1:三张图的绘制函数为什么不拆成三个组件?
本应用是一个「图表 Demo」,三张图共享同一个画布、同一套坐标系常量(left/right/top/bottom/plotW/plotH)和同一套状态(tab/active)。拆成三个组件反而要传递大量共享状态,徒增复杂度。真实产品中如果图表要独立复用,可以抽成 ChartCanvas 子组件接收数据和类型参数——但本应用的「单画布三投影」更利于教学对照。
Q2:为什么折线图动画用 setInterval 而不用动画 API?
ArkUI 提供了 animateTo / keyframeAnimate 等声明式动画,但它们针对组件属性变化;Canvas 的画面是命令式绘制的,声明式动画无法直接驱动「画到哪」。所以折线动画用 setInterval + 进度重算,本质是「手动逐帧驱动 Canvas」——这也是所有 Canvas 动画的通用做法。
Q3:图表数据改变后如何刷新?
如果是静态数据(本应用),数据变化 = 改常量 + 调 redrawCurrent()。如果是动态数据(实时监控),把数据放到成员变量(如 @State barData: BarItem[]),数据更新后调用 redrawCurrent() 即可——绘制层只认「当前数据」,与数据来源无关。
Q4:触摸命中为什么用 Down 而不是 Move?
图表是「点选」交互(选柱子、选扇区),Down 一次即可。Move 高频触发会带来大量无意义的命中检测与重绘。如果要做「手指划过高亮跟随」的效果,再用 Move 并做节流(如每 3 帧检测一次)。
Q5:niceMax 的 1.25 系数和 50 步长是魔法数字吗?
是「启发式参数」,但不是凭空来的:1.25 保证数据最高点上方留 20% 空间;50 是「周销量」量级的自然步长(120~320 范围)。这两个参数放在 niceMax 方法内集中管理,想调整只改一处——把魔法数字集中在工具函数里,是控制启发式参数的最佳实践。
Q6:饼图百分比文字在超小扇区会重叠吗?
会。如果某个扇区占比 < 5%,它的环带中线可能容不下「5%」三个字符。解决思路:小扇区百分比移到扇区外侧并用引线连接(leader line),或者直接不显示小扇区的百分比(图例里已有)。本应用数据恰好没有超小扇区,未做此处理——真实产品必须考虑。
Q7:本应用与前面两个应用的核心区别是什么?
签名板/涂鸦画板的画面由用户输入(触摸)决定,是「记录型」;图表的画面由数据决定,是「投影型」。这导致绘制思维的根本差异:前者的数据是「历史」,重绘 = 重放历史;后者的数据是「现状」,重绘 = 重新投影。理解这个区别,你就掌握了本系列两条主线的分野。
十二、与后续应用(4/5)的衔接
应用 3 是「静态图表 + 短时动画」的代表,它给后续两个应用留下的技术遗产:
| 技术点 | 应用 3 的使用 | 应用 4/5 的升级 |
|---|---|---|
| 角度/极坐标 | 饼图扇区 | 时钟表盘刻度、指针 |
| setInterval 动画 | 折线生长 | 时钟每秒走时 |
| 命中检测 | 图表点选 | 仪表盘无交互(纯展示) |
| 状态 + 重绘 | tab 切换 | 时间驱动重绘 |
| 绘制函数开头重置状态 | 三图互不污染 | 每帧全量重绘的纪律 |
图表应用的「每帧重绘 + 状态驱动」范式,在应用 4(时钟)变成「时间驱动」,在应用 5(粒子)变成「帧循环 + 对象池」——应用 3 是「从事件驱动到时间驱动」的过渡站。学完本节,你已经具备阅读后续所有绘制代码的完整基础。
十三、数据图表应用收官总结
回顾整个应用 3,用一张「从数据到交互」的完整链路做收官:
数据(BAR_DATA / LINE_DATA / PIE_DATA)
↓ 归一化
比例(v/maxY、v/total)
↓ 映射
几何(柱矩形 / 折线点 / 扇区角度)
↓ 绘制
画面(网格 + 渐变 + 标签 / 面积 + 动画 / 环形 + 百分比)
↓ 反投影
交互(槽位命中 / 最近点 / 距离+角度)
五步链路中,前四步是「画」,最后一步是「查」。绘制与命中共用同一套几何参数(left/slot/maxY/angle),这是整个应用 3 代码正确性的根基——任何一步的几何公式变了,另一步必须同步变,否则画面与交互就会错位。
13.1 应用 3 的五个工程习惯
本应用在全系列里首次引入了几个「工程化」习惯,值得沉淀:
| 习惯 | 体现 | 价值 |
|---|---|---|
| 状态显式复位 | 每个绘制函数开头重置样式 | 三图互不污染 |
| 全局资源防重入 | lineTimer 守卫 | 计时器不泄漏、不叠加 |
| 生命周期兜底 | aboutToDisappear 清理 | 页面销毁后无残留 |
| 魔法数字集中 | niceMax / 常量区 | 调参只改一处 |
| 几何参数单一真相源 | left/slot 绘制命中共用 | 画面与交互不错位 |
这五个习惯在应用 4(时钟)会变成「每帧重绘的纪律」,在应用 5(粒子)会变成「对象池 + 帧循环」的基石——工程习惯是跨应用复用的隐形资产。
13.2 给读者的自测清单
学完应用 3,你应该能独立回答:
- 柱状图的 y 轴映射为什么是
h - bottom - plotH * v / maxY?(答案:y 向下 + 值越大越靠上) - 折线动画为什么「进度重算几何」而不是逐帧缓存?(答案:画面永远自洽、可切走切回)
- 饼图命中为什么先比距离再比角度?(答案:两级过滤,防止圆外误判)
- 三张图为什么能共用一个画布?(答案:单画布三投影,绘制函数互不污染)
能答对这四个问题,说明你已经真正理解了「数据域 ↔ 像素域」这条主线。带着这份理解进入应用 4(时钟仪表盘),你会看到同一套思想的「时间驱动」版本。
更多推荐


所有评论(0)