在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

一、设计理念:图表是数据的最诚实投影

图表是自定义绘制的「经典主场」:组件化的图表库(如 echarts)要引入大依赖、定制难;而 Canvas 手绘图表轻、可控、无依赖,还能获得第一手的绘制原理。本应用用 Canvas 实现三种最常用的图表形态:

形态 用途 本次数据
柱状图 离散分类的数值对比 一周销量(件)
折线图 连续趋势变化 一周步数(千步)
饼图 整体构成占比 时间分配(%)

技术亮点:坐标系与网格、渐变柱体、绘制动画、扇区触摸命中——把 Canvas 的路径、样式、变换、动画 API 串成一套完整实践。

为什么第三个应用选图表

签名板(应用 1)和涂鸦画板(应用 2)解决的是「自由绘制」,画面由用户手指产生。图表应用则完全不同:画面由数据产生,用户只是查看者。这个转变带来三个全新的技术课题:

  1. 坐标系反转:前两个应用直接使用 Canvas 的屏幕坐标(原点左上、y 向下);图表需要数学坐标(原点左下、y 向上)——必须自己建立「数据域 → 像素域」的映射;
  2. 精确几何:笔迹可以「差不多就行」,图表的柱子、刻度、标签必须精确对齐——一像素的偏差都影响可读性;
  3. 主动动画:前两个应用的画面由用户操作触发重绘;图表的折线生长动画是程序主动驱动的——引入计时器与进度状态。

这三个课题分别对应本系列后续三篇的技术主线:映射(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' }
];

模型设计要点

  1. 柱状图需要 name + value:x 轴要显示分类名;
  2. 饼图必须带 color:每个扇区独占颜色,色板内置在数据里——UI 图例也复用这份数据,数据是唯一的真相源
  3. 折线图只有数值: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 只是换绘制函数——与涂鸦画板「一个画布 + 绘制参数」同构。好处:

  1. 上下文零重建:Canvas 组件一直存在,onReady 只触发一次;
  2. 绘制函数可组合:三套绘制逻辑互相独立,互不污染;
  3. 动画好控制:折线图的动画计时器只需要管「当前画哪种图」。

五、触摸交互的「反绘制」

绘制是把数据变成像素,触摸是把像素反推回数据——两者互为逆运算

图表 正向(绘制) 反向(命中检测)
柱状图 value → 柱体 (x, y, w, h) 触摸 x → slot 下标 → 数据项
折线图 index → 点 (x, y) 触摸 x → 最近点下标
饼图 value → 扇区角度 触摸向量 → atan2 角度 → 扇区

每个图表配一个「命中检测」函数,触摸 Down 时执行并触发重绘(高亮/外弹)。交互 = 命中检测 + 状态 + 重绘,与绘制层保持同样的「数据驱动」范式。

5.1 为什么图表用 Down 而不是 Move 做交互

三种图表的交互都是「点选」语义——点一下柱子看高亮、点一下扇区看外弹。Down 触发一次即可,为什么不用 Move 连续跟踪?

  1. 交互语义:图表是「查看」工具,不是「绘制」工具,用户的操作是「选择」而非「拖动」,Down 足够;
  2. 性能:Move 事件 60~120Hz 触发,每次都要做命中检测 + 重绘,纯属浪费;Down 一帧一次,压力为零;
  3. 一致性:三种图表的命中检测都挂在 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、地图、海量点),组件库仍是更务实的选择——技术选型没有对错,只有场景

九、动手练习

学完设计篇,建议动手验证以下四点:

  1. 改数据验证映射:把 BAR_DATA 的最大值改成 999,观察 niceMax 如何把轴上限变成 1250(999×1.25=1248.75 → ceil(1248.75/50)×50=1250),验证「好看上限」算法;
  2. 改留白验证布局:把 left 从 40 改成 10,观察 y 轴刻度文字被裁切——理解「留白 = 标签保护区」;
  3. 负值数据实验:把某个 value 改成 -50,观察当前映射公式的行为(柱体向下画),思考需要什么改动(vMin 不再为 0);
  4. 手动投影练习:不写代码,用纸笔画一个「x 从 0 到 100、像素区宽 200」的映射表,算出 v=37 对应的 px——体会「归一化 × 缩放 + 平移」的每一步。

十、深入思考:三种图表的共性抽象

学完三张图的绘制后,回头看它们的共性,你会得到一个「图表绘制通用流水线」:

数据 → 归一化(数据域→比例) → 映射(比例→像素) → 绘制(路径/填充/文字)
                                            ↑
                                    命中(像素→比例→数据)
阶段 柱状图 折线图 饼图
归一化 v/maxY v/maxY v/total
映射 线性→矩形 线性→点 线性→角度
绘制 fillRect lineTo+arc arc×2
命中 槽位除法 最近点 距离+角度

四张表并排看,你会发现三张图只有「映射目标形状」不同,其余全部相同。这就是为什么掌握「数据域 ↔ 像素域」这条主线后,任何图表(雷达图、散点图、热力图)都能触类旁通。

十一、关于「诚实投影」的进一步讨论

本节标题叫「图表是数据的最诚实投影」,这句话有两层含义,值得展开:

  1. 技术层面:画面 = 数据的函数,数据变画面变,无任何人为修饰——这是数据驱动绘制的技术承诺;
  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([]);   // 用完复位,防止影响坐标轴

三个要点

  1. setLineDash([4, 4]):4 像素实线 + 4 像素空白循环——虚线网格既不抢柱体视觉,又提供读数参考。用完必须 setLineDash([]) 复位,否则坐标轴、柱体都会带着虚线(与合成模式同款「全局状态污染」陷阱);
  2. 刻度文字右对齐textAlign='right' 让所有刻度值右缘对齐在 y 轴左侧同一列——对齐是刻度文字的基本礼仪;
  3. 垂直位置 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);
  ...
}

逐项拆解

  1. 槽位 vs 柱宽slot = plotW / n 是每根柱的领地,柱体只占 55%,留出 45% 空隙——柱间空隙是柱状图的识别特征,满宽相连就成直方图了;
  2. 柱体居中x = left + slot*i + (slot - barW)/2,每根柱在自己的槽位内水平居中;
  3. 渐变方向createLinearGradient(0, y, 0, h-bottom) —— 从柱顶 (y) 到柱底,垂直渐变;深色顶 → 浅色底,像光照从上方打下,柱体有了体积感
  4. 高亮换深色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 状态残留的三个陷阱
  1. fillStyle 残留:第一根柱用了红色,第二根柱忘记设置 fillStyle,就会继承红色——颜色错乱且难排查;
  2. textBaseline 残留:y 轴刻度用了 middle,画 x 轴标签时忘记改回 top,标签的垂直位置整体偏移;
  3. 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 到路径,是绘制自由度的一次跃迁——路径可以表达任意闭合形状,矩形只是它的特例。

十一、动手练习

  1. 柱宽实验:把 barW = slot * 0.55 依次改成 0.3 / 0.8 / 1.0,观察柱间空隙变化,找到你最喜欢的比例;
  2. 网格虚实实验:把 setLineDash([4, 4]) 改成 [1, 4](细虚线)和 [](实线),对比网格对柱体的干扰程度;
  3. 渐变方向实验:把渐变从「垂直」改成「水平」createLinearGradient(x, 0, x+barW, 0),观察柱体从「有高度感」变成「有宽度感」;
  4. 命中容差实验:点柱间空隙,观察当前代码「命中相邻柱」的行为,再思考若要「空隙不命中」需要怎么改(比较 x 与柱体左右边界);
  5. 刻度算法实验:把 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 折线图的「高级感」从哪来

同样的数据,为什么有的折线图看起来专业、有的像「小学生作业」?差异通常在三个细节:

  1. 线下有渐变面积:纯线条 + 渐变面积 = 现代图表标配,面积让「线的领地」有视觉重量;
  2. 数据点与线分离:白底描边点让线「穿过」点而不是「压住」点,层次分明;
  3. 入场动画:静态图 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,取「最近」而非「左侧」);命中后:

  1. 该点放大变红(半径 3→5,填充红);
  2. 点上方画数值气泡: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 折线图动画的设计取舍回顾

动画实现中有三个决策值得复盘,它们共同决定了动画的「顺滑与健壮」:

  1. 用进度重算几何,而非逐帧缓存:不保存每一帧的画面,而是每帧从进度推导「画到哪」——画面永远自洽,切走切回都不出错;
  2. 线与面积共享进度:两套路径使用完全相同的 n 和 frac,视觉同步,不会出现「面积画完线没到」;
  3. 进度是纯数据: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 兜底

核心规则

  1. 启动必须有守卫(防重入);
  2. 完成必须清资源(clearInterval + 置 -1);
  3. 销毁必须兜底(aboutToDisappear 里再清一次——页面退出时计时器可能还在跑,不清理会泄漏);
  4. 状态与绘制解耦:进度是数据,绘制函数每帧读取进度重新投影——动画中途任何时刻的状态都是「当前进度的正确画面」。

这条模板在应用 5(粒子特效)会升级为「帧循环 + 对象池」,但「生命周期四阶段」的骨架完全一致。

十二、折线图与柱状图的绘制思维对比

同为图表,折线图和柱状图的绘制思维有本质区别,理解这个区别能帮你更快上手更多图表:

维度 柱状图 折线图
元素类型 离散(每根柱独立) 连续(整条线)
绘制单位 逐根柱 fillRect 一条多段路径 lineTo
状态依赖 柱间无依赖 点与点有顺序关系
动画方式 无(或逐柱生长) 进度截断 + 插值
命中检测 槽位除法(一维) 最近点(一维距离)

柱状图是「逐个画」,折线图是「连成线」——前者把数据看成独立个体,后者把数据看成连续序列。图表的形态决定了你的绘制思维:离散数据逐个画,连续数据连起来画。

十三、动手练习

  1. 动画速度实验:把 this.lineProgress += 0.04 改成 0.02 / 0.08,观察动画时长变化,理解「进度步长 = 动画速度」;
  2. 面积透明度实验:把 rgba(59, 130, 246, 0.35) 改成 0.6 / 0.05,观察面积色带的浓淡对折线可读性的影响;
  3. 最近点算法实验:把命中检测的 Math.round 改成 Math.floor,观察触摸点下方与左侧数据点的行为差异;
  4. 数据点样式实验:把数据点从「白底描边」改成「纯色实心」,观察线穿过点 vs 点盖住线的视觉差异;
  5. 无动画实验:把 lineProgress 直接置 1(跳过动画),对比静态图与动画图的观感,体会「入场动画」的价值。

饼图实战:环形扇区、百分比标注与触摸外弹

一、目标

饼图展示「整体构成」。本应用做成**环形(donut)**而非实心——环带让百分比标注有落点、视觉更现代。要素:

  1. 环形扇区(外弧 + 内弧反向闭合);
  2. 扇区中间环带上的百分比标注;
  3. 中心标题;
  4. ArkUI 图例(复用数据源);
  5. 触摸扇区外弹(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;                                // 游标推进
}

扇形建模三要素

  1. 起点 -π/2(12 点方向):Canvas 的 arc 以 3 点方向为 0°,饼图惯例从顶部开始,所以起点偏移 -90°;
  2. 跨度与数值成正比value/total × 2π——占比越大弧越长,这就是「饼」;
  3. 游标累加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();

关键 APIarc(x, y, r, startAngle, endAngle, counterclockwise)——第六个参数 true 表示逆时针。画环形的经典套路:

  1. 外弧从 angle 顺时针到 angle+span;
  2. 内弧从 angle+span 逆时针回到 angle;
  3. 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;
}

命中检测三部曲

  1. 距离过滤:点到圆心距离超外半径直接忽略——先粗筛(圆内),再做细判(角度);
  2. 角度换算atan2(dy, dx) 给出 -π~π 的屏幕角度(0° 在 3 点方向),+π/2 对齐绘制的 -π/2 起点,负数加 2π 归一到 [0, 2π)——命中角度与绘制角度必须同坐标系,这是逆运算的第一纪律;
  3. 区间遍历:角度落在哪个扇区的 [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 点)开始是设计惯例,原因有二:

  1. 视觉重心:第一扇区从顶部开始,顺着顺时针展开,读者「阅读」的顺序与钟表一致,符合直觉;
  2. 命中一致性:触摸检测用 atan2 得到角度后要 +π/2 对齐绘制起点——如果绘制起点是 0°(3 点),命中换算也要相应改,两处约定必须始终一致。

核心纪律:绘制角度与命中角度必须用同一套坐标系——这是 3-4 篇反复强调的第一原则。

10.3 角度与弧度的换算
角度 弧度 说明
0 3 点方向
90° π/2 6 点方向
180° π 9 点方向
270° 3π/2(-π/2) 12 点方向
360° 回到起点

代码里 span = value/total * 2π 把占比换算成弧度跨度;mid = angle + span/2 找扇区中分线——所有的比例、角度、弧度换算都在数学上闭环,这也是饼图「绘制与命中互为逆运算」的根基。

十一、进阶:饼图的常见变体

理解了基础环形饼图,下面三个变体都是「在基础模型上加一点东西」,帮你看到模型的扩展性:

11.1 半圆环(仪表盘风格)

只需把总角度从 改成 π(或 1.5π),起点从 -π/2 改成 0π,就得到半圆仪表盘——第 4 个应用(时钟仪表盘)的进度环就是这种思路的延伸。

11.2 玫瑰图(南丁格尔图)

把每个扇区的半径与数值挂钩(而不是角度),得到玫瑰图——数值越大扇区越宽越长。绘制差异只在 r 的计算:

const r = minR + (value / maxValue) * (maxR - minR);  // 半径随数值变化
11.3 多层环(同心环)

用多个半径画多个环,每个环代表一个维度——就是「旭日图」的雏形。数据模型升级为 Layer[],绘制时从外到内逐层画,命中时从外向内逐层判断。

这些变体都共享同一套「极坐标 + 角度游标」模型——掌握了基础模型,变体只是参数变化。

十二、动手练习

  1. 起点实验:把饼图起点从 -π/2 改成 0(3 点方向),观察所有扇区整体旋转 90°,并思考命中检测需要同步改什么;
  2. 内半径实验:把 rIn = r * 0.62 改成 0.3 / 0.85,观察环带从「粗环」到「细环」的变化,找到百分比文字可读性最好的宽度;
  3. 外弹距离实验:把 offset 从 10 改成 4 / 18,观察外弹幅度的视觉差异与命中容差(pieOuter = r + 14)的匹配;
  4. 比例换算实验:把 PIE_DATA 的某个 value 改成 0,观察该扇区消失、其余扇区重新均分 360°——验证「占比 = value/total」的实时性;
  5. 极坐标练习:不写代码,算出「占 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 点选)。

五、应用小结

数据图表完整落地了**「数据域 ↔ 像素域」映射**的方法论:

  1. 数据模型:三组静态数据,饼图数据内嵌颜色(单一真相源);
  2. 坐标系:四边留白 + niceMax 上限 + 线性映射;
  3. 三图投影:柱(网格/渐变)、线(面积/动画)、饼(环/角度),一套坐标系三种画法;
  4. 交互:绘制与命中互为逆运算,高亮/外弹 = 状态 + 重绘;
  5. 动画: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 个应用的粒子动画是「持续、高频、对象驱动」——两种动画的资源管理策略完全不同,届时会展开。

九、动手练习:把图表「玩坏」

  1. 改数据结构:给 BAR_DATA 加两根柱子(8 个分类),观察槽位自适应、柱宽不变、x 轴标签自动排开——验证「数据驱动布局」;
  2. 改动画时长:把 += 0.04 改成 += 0.01,动画从 0.75 秒变成 3 秒,观察进度步长与动画时长的线性关系;
  3. 改饼图数据:把 PIE_DATA 加一项「休息 10」,观察总占比仍为 100%、各扇区角度自动缩小——验证「占比 = value/total」;
  4. 交叉触发实验:柱状图高亮后切折线图再切回,观察 barActive 是否复位——理解 switchTab 里的状态重置;
  5. 双图表联动:给柱状图加「点击柱子后折线图高亮对应点」的联动,验证「状态 + 重绘」范式可以跨图表复用。

十、三张图的绘制管线总览

最后把三张图的完整绘制管线并排,作为应用 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,你应该能独立回答:

  1. 柱状图的 y 轴映射为什么是 h - bottom - plotH * v / maxY?(答案:y 向下 + 值越大越靠上)
  2. 折线动画为什么「进度重算几何」而不是逐帧缓存?(答案:画面永远自洽、可切走切回)
  3. 饼图命中为什么先比距离再比角度?(答案:两级过滤,防止圆外误判)
  4. 三张图为什么能共用一个画布?(答案:单画布三投影,绘制函数互不污染)

能答对这四个问题,说明你已经真正理解了「数据域 ↔ 像素域」这条主线。带着这份理解进入应用 4(时钟仪表盘),你会看到同一套思想的「时间驱动」版本。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐