Day 50 · 手写柱状图 — 比例尺、刻度算法、动画,把"图表"拆成数学
ECharts 一个
type: 'bar'背后发生的事:计算坐标轴范围 → 生成"好看"的刻度 → 把数据映射到像素 → 绘制矩形 → 播放入场动画。今天全部自己写一遍。写完你会发现:图表不是"画图",是"数学 + 绘图 + 动画"的三明治。
目录
- 一、图表解剖学:所有图表共享同一副骨架
- 二、线性比例尺:数据域 → 像素域
- 三、nice 刻度算法:让坐标轴"好看"
- 四、绘制坐标轴与网格
- 五、柱状图主体渲染
- 六、入场动画:高度从 0 长出来
- 七、hover 交互与 tooltip
- 八、封装成可复用的 BarChart 类
- 九、常见坑点
- 十、自测挑战
- 十一、总结
一、图表解剖学:所有图表共享同一副骨架
打开任何一张统计图(柱状/折线/散点),都能拆出同样四块:
┌─────────────────────────────────────────────┐
│ 标题区 (title) │
│ ┌─────────────────────────────────────────┐ │
│ │ legend │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ 绘图区 (grid) │ │ │
│ │ │ 数据矩形在这里按比例尺摆放 │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────┘ │ │
│ │ x 轴标签 x 轴标签 │ │
└─┴─────────────────────────────────────────┴─┘
y 轴标签在左侧,与 grid 左边缘对齐
| 区块 | 职责 | 需要计算的核心量 |
|---|---|---|
| grid | 承载数据图形 | 上下左右四边距(给轴标签留空间) |
| yAxis | 数值刻度 | 刻度范围 + 刻度数组(今天第三节) |
| series | 柱子本体 | 每根柱子的 x/y/w/h(比例尺算出) |
| tooltip | 悬浮详情 | 命中检测(今天第七节) |
💡 先画骨架再画数据:坐标和网格是"地",数据图形是"楼"——顺序反了就要重画。ECharts 内部渲染顺序同样是:组件(轴/网格)→ 系列(柱/线)。
二、线性比例尺:数据域 → 像素域
2.1 实现
/**
* 线性比例尺:把数据值线性映射到像素值
* @param dMin 数据域下限(如 0℃)
* @param dMax 数据域上限(如 100℃)
* @param pMin 像素域下限(如 grid 底部 y=380)
* @param pMax 像素域上限(如 grid 顶部 y=40)
* @returns 映射函数
*/
function linear(
dMin: number, dMax: number,
pMin: number, pMax: number
): (data: number) => number {
// 防除零:数据全相等时退化为取像素中点
const range = dMax - dMin || 1;
return (data: number): number => {
return pMin + ((data - dMin) / range) * (pMax - pMin);
};
}
2.2 坐标系方向的坑
数学坐标系 y 向上,Canvas 坐标系 y 向下——这是新手图表错位的头号原因:
// y 轴映射:数据 0 在"像素大"的一端(屏幕下方),数据大值在"像素小"的一端(屏幕上方)
const yScale = linear(0, dataMax, gridBottom, gridTop); // 注意 pMin=下、pMax=上
const xScale = linear(0, count, gridLeft, gridRight); // x 轴方向正常
// 例:数据值 50、gridBottom=380、gridTop=40、dataMax=100
yScale(50); // 380 + 0.5 * (40-380) = 210 → 屏幕中部 ✅
🧠 记忆法:y 比例尺的像素端点永远是"下前上后"(bottom 在前、top 在后),写反了柱子会倒着长。
三、nice 刻度算法:让坐标轴"好看"
3.1 为什么需要
数据最大值 83,如果直接 6 等分:刻度是 0 / 13.8 / 27.7 / 41.5 …——没有比这更丑的坐标轴了。真实图表库的刻度永远是 10 / 20 / 50 的整数倍。
3.2 nice-number 算法(D3/ECharts 同款思路)
/**
* 生成"好看"的刻度间隔
* 步骤:粗算间隔 → 取整到 1/2/5×10^n → 微调刻度数量
* @param range 数据范围(max - min)
* @param maxTicks 期望的最大刻度数
*/
function niceStep(range: number, maxTicks: number): number {
// 1. 粗算原始间隔
const rawStep = range / maxTicks;
// 2. 求数量级:10^floor(log10(rawStep))
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
// 3. 归一化到 [1, 10) 区间,取最近的"好看值"(1、2、5、10)
const norm = rawStep / mag;
let nice: number;
if (norm < 1.5) nice = 1;
else if (norm < 3) nice = 2;
else if (norm < 7) nice = 5;
else nice = 10;
// 4. 还原数量级
return nice * mag;
}
/**
* 生成完整刻度数组:从"向下取整的 min"到"向上取整的 max"
*/
function niceTicks(min: number, max: number, maxTicks = 6): number[] {
const step = niceStep(max - min, maxTicks);
// 起点对齐到 step 的整数倍(0~83 → 从 0 开始;12~87 → 从 10 开始)
const start = Math.floor(min / step) * step;
const end = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = start; v <= end + 1e-9; v += step) {
ticks.push(Number(v.toFixed(10))); // 消除浮点累加误差
}
return ticks;
}
// 验证:
niceTicks(0, 83, 6); // [0, 20, 40, 60, 80, 100] step=20
niceTicks(0, 7, 5); // [0, 2, 4, 6, 8] step=2
niceTicks(12, 87, 6); // [10, 30, 50, 70, 90] step=20
3.3 关键洞察:刻度决定轴范围
算出 nice 刻度后,y 轴范围不再是数据的 min/max,而是刻度的首尾(0~100 而不是 0~83)。比例尺必须用刻度范围初始化——否则最上面的刻度线和数据对不上。
const ticks = niceTicks(0, dataMax, 6);
const yScale = linear(ticks[0], ticks[ticks.length - 1], gridBottom, gridTop);
四、绘制坐标轴与网格
/**
* 绘制 y 轴:网格线 + 刻度文字
* @param ctx 画布上下文
* @param ticks 刻度数组
* @param yScale y 比例尺
* @param grid 绘图区矩形
*/
function drawYAxis(
ctx: CanvasRenderingContext2D,
ticks: number[],
yScale: (v: number) => number,
grid: Rect
): void {
ctx.save();
ctx.font = "11px sans-serif";
ctx.fillStyle = "#6a7a94";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (const t of ticks) {
const y = yScale(t);
// 网格线:横向贯穿 grid(半透明,不抢数据视觉)
ctx.strokeStyle = "rgba(106,122,148,0.15)";
ctx.beginPath();
ctx.moveTo(grid.x, y);
ctx.lineTo(grid.x + grid.w, y);
ctx.stroke();
// 刻度文字:grid 左侧留 8px
ctx.fillText(String(t), grid.x - 8, y);
}
ctx.restore();
}
/**
* 绘制 x 轴:类目标签(车间名等,工业场景常见)
*/
function drawXAxis(
ctx: CanvasRenderingContext2D,
labels: string[],
xScale: (v: number) => number,
grid: Rect,
bandWidth: number
): void {
ctx.save();
ctx.font = "11px sans-serif";
ctx.fillStyle = "#6a7a94";
ctx.textAlign = "center";
ctx.textBaseline = "top";
labels.forEach((label, i) => {
// 柱子中心 = 类目起点 + 半个带宽
const cx = xScale(i) + bandWidth / 2;
ctx.fillText(label, cx, grid.y + grid.h + 8);
});
ctx.restore();
}
五、柱状图主体渲染
5.1 带宽计算:柱宽与间隔的分蛋糕问题
/**
* 计算每根柱子的布局
* @param count 柱子数量
* @param gridLeft 绘图区左边缘
* @param gridWidth 绘图区宽度
* @param barRatio 柱宽占带宽的比例(0.6 = 40% 是间隔,视觉最舒服的经验值)
*/
function layoutBars(
count: number, gridLeft: number, gridWidth: number, barRatio = 0.6
): { bandWidth: number; barWidth: number } {
const bandWidth = gridWidth / count; // 每根柱子分到的"蛋糕"
const barWidth = bandWidth * barRatio; // 实际柱宽
return { bandWidth, barWidth };
}
5.2 绘制柱子(含 hover 高亮状态)
/**
* 绘制单根柱子(从数据值到屏幕矩形的完整映射)
*/
function drawBar(
ctx: CanvasRenderingContext2D,
index: number,
value: number,
animValue: number, // 动画期间的"当前值"(见第六节)
xScale: (v: number) => number,
yScale: (v: number) => number,
bandWidth: number, barWidth: number,
hovered: boolean
): void {
const x = xScale(index) + (bandWidth - barWidth) / 2; // 带内居中
const y = yScale(animValue); // 柱顶(动画值,不是终值!)
const h = yScale(0) - y; // 柱高 = 轴底 - 柱顶
ctx.save();
// 工业风:蓝色渐变柱身 + hover 变亮
const grad = ctx.createLinearGradient(0, y, 0, y + h);
if (hovered) {
grad.addColorStop(0, "#4fd8ff");
grad.addColorStop(1, "#0090d0");
} else {
grad.addColorStop(0, "#00c6ff");
grad.addColorStop(1, "#0072b0");
}
ctx.fillStyle = grad;
// 顶部圆角柱(圆角只在上沿,下沿贴轴)
const r = Math.min(4, barWidth / 2, h);
ctx.beginPath();
ctx.moveTo(x, y + h);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.lineTo(x + barWidth - r, y);
ctx.arcTo(x + barWidth, y, x + barWidth, y + r, r);
ctx.lineTo(x + barWidth, y + h);
ctx.closePath();
ctx.fill();
ctx.restore();
}
六、入场动画:高度从 0 长出来
6.1 原理
动画期间不绘制终值,绘制插值:animValue = 0 + (value - 0) * ease(t)。ease 用 easeOutCubic(先快后慢,观感自然)。
/**
* easeOutCubic 缓动:t∈[0,1] → 先冲后缓
*/
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
/**
* 播放入场动画(rAF + 时间戳,帧率无关——Day 34 的知识)
* @param duration 动画时长(毫秒)
* @param onFrame 每帧回调:收到进度 t(0~1),由调用方算插值并重绘
*/
function animate(duration: number, onFrame: (t: number) => void): void {
const start = performance.now();
function frame(now: number): void {
const t = Math.min((now - start) / duration, 1);
onFrame(easeOutCubic(t));
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
// 使用:动画期间用插值画柱子,结束后切回真实值
animate(600, (t) => {
animProgress = t;
redraw(); // redraw 内部 drawBar 用 value * animProgress 作为 animValue
});
6.2 错开动画(工业大屏的标配质感)
每根柱子延迟 index × 30ms 启动,形成波浪式生长:
// 第 i 根柱子的独立进度:
// 前 delay 时间内为 0,之后走 ease
const local = Math.max(0, Math.min((elapsed - i * 30) / 600, 1));
const animValue = value * easeOutCubic(local);
七、hover 交互与 tooltip
7.1 命中检测:点在柱内?
/**
* 检测鼠标点中了哪根柱子(把 Day 45 的 AABB 复用过来)
* @returns 命中的柱子下标,未命中返回 -1
*/
function hitBar(
p: { x: number; y: number },
values: number[],
xScale: (v: number) => number, yScale: (v: number) => number,
bandWidth: number, barWidth: number
): number {
for (let i = 0; i < values.length; i++) {
const x = xScale(i) + (bandWidth - barWidth) / 2;
const y = yScale(values[i]);
// AABB:注意 y 轴向下,柱子的 top 是 y、bottom 是 yScale(0)
if (p.x >= x && p.x <= x + barWidth && p.y >= y && p.y <= yScale(0)) {
return i;
}
}
return -1;
}
7.2 tooltip 绘制
/**
* 绘制悬浮提示框(跟随鼠标,防出界翻转)
*/
function drawTooltip(
ctx: CanvasRenderingContext2D,
p: { x: number; y: number },
text: string,
canvasW: number
): void {
ctx.save();
ctx.font = "12px sans-serif";
const w = ctx.measureText(text).width + 16;
const h = 26;
// 防出界:鼠标在右半屏时,框翻转显示在左侧
const x = p.x + 14 + w > canvasW ? p.x - w - 14 : p.x + 14;
const y = p.y - 34;
ctx.fillStyle = "rgba(13,20,33,0.92)";
ctx.strokeStyle = "#2a3a55";
ctx.beginPath();
ctx.roundRect(x, y, w, h, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#e0e8f0";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(text, x + 8, y + h / 2);
ctx.restore();
}
八、封装成可复用的 BarChart 类
8.1 接口设计
/**
* Canvas 柱状图组件:数据进,图形出,交互内建
* 用法:
* const chart = new BarChart(canvas);
* chart.setData({ labels: ["1车间","2车间","3车间"], values: [82, 67, 91] });
*/
export class BarChart {
private ctx: CanvasRenderingContext2D;
private labels: string[] = [];
private values: number[] = [];
private hoverIdx = -1;
constructor(private canvas: HTMLCanvasElement) {
this.ctx = canvas.getContext("2d")!;
this.bindEvents();
}
/** 更新数据并播放入场动画 */
setData(data: { labels: string[]; values: number[] }): void {
this.labels = data.labels;
this.values = data.values;
this.playEnterAnimation();
}
/** 绑定 hover 事件(防抖节流不必要:mousemove 绘制量极小) */
private bindEvents(): void {
this.canvas.addEventListener("mousemove", (e) => {
this.hoverIdx = this.hitTest(e.offsetX, e.offsetY);
this.redraw(1); // 非动画状态,进度为 1
if (this.hoverIdx >= 0) {
this.drawTooltipAt(e.offsetX, e.offsetY);
}
});
this.canvas.addEventListener("mouseleave", () => {
this.hoverIdx = -1;
this.redraw(1);
});
}
/** 入场动画:错开生长 */
private playEnterAnimation(): void {
const start = performance.now();
const frame = (now: number): void => {
const elapsed = now - start;
// 最晚一根柱子结束的时刻
const total = 600 + this.values.length * 30;
const progress = Math.min(elapsed / total, 1);
this.redraw(1, elapsed);
if (progress < 1) requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
/** 完整重绘:轴 → 网格 → 柱(按动画进度) */
private redraw(_final: number, elapsed = Infinity): void {
/* 组装第二~七节的所有函数:ticks → scales → axes → bars(动画插值) */
}
/** 命中检测 + tooltip + 布局计算(细节略,组装课就留给自测 T2) */
private hitTest(x: number, y: number): number { return -1; }
private drawTooltipAt(x: number, y: number): void {}
}
💡 DPR 处理别忘了:canvas 物理尺寸 × devicePixelRatio、ctx.scale(dpr, dpr)——Day 29 的初始化模板直接复用,否则高分屏上全是毛边。
九、常见坑点
坑 1:柱子"倒着长"
y 比例尺像素端点写反(linear(0, max, gridTop, gridBottom))。记住:y 轴永远是"下前上后"。
坑 2:最顶刻度线与数据对不齐
比例尺用了数据范围(0~83),刻度却画到 100——刻度数组和比例尺必须用同一套范围(都用 niceTicks 的首尾)。
坑 3:动画结束后 hover 闪烁
动画期间和结束后的重绘走了不同代码路径(一个画插值、一个画终值)。统一:redraw(progress) 接收进度参数,结束时固定传 1。
坑 4:柱子太细/太粗
数据只有 3 根柱时 barRatio=0.6 会得到巨柱。给 barWidth 加上限:Math.min(barWidth, 60)(ECharts 的 barMaxWidth 就是这个)。
坑 5:浮点刻度误差
0.1 + 0.2 = 0.30000000000000004 出现在刻度文字上。生成刻度时 toFixed(10) 再 Number() 还原(见 3.2 代码)。
十、自测挑战
T1 · 验证 nice-number(15 分钟)
对以下数据各算出刻度数组:(0, 100, 6)、(3, 47, 5)、(0.02, 0.08, 4)、(9998, 10002, 5)。最后一个会考察你对"数据全挤在一个数量级"的理解(hint:step 会小到 1,刻度是 9998/9999/10000/10001/10002——这正是"数据范围远小于数据量级"时图表该有的样子)。
T2 · 补全 BarChart.redraw(40 分钟)
把第二~七节的所有函数组装进 redraw,跑通完整流程:DPR 初始化 → 刻度 → 比例尺 → 轴 → 错开动画的柱 → hover 高亮 + tooltip。这是今天的核心作业。
T3 · 数值标签(15 分钟)
在每根柱子顶部上方 4px 处绘制数值文字(如 82℃),字号 10px。注意:动画期间标签跟着柱顶一起动。
T4 · 对数轴(进阶,30 分钟)
数据跨 3 个数量级(1、50、3000、80000)时线性轴会把小值压扁。实现 logScale:px = pMin + (log(data) - log(dMin)) / (log(dMax) - log(dMin)) * (pMax - pMin)。思考:什么工业场景需要对数轴?(设备故障率分布、传感器灵敏度)
十一、总结
今天你亲手实现了 ECharts type: 'bar' 的完整内核:
| 你写的 | ECharts 对应配置 |
|---|---|
linear() |
yAxis.min / max(自动计算时内部就是它) |
niceTicks() |
yAxis.splitNumber + nice 策略 |
layoutBars() |
series.barWidth / barCategoryGap |
animate() + easeOutCubic |
animationDuration / animationEasing: 'cubicOut' |
| 错开动画 | animationDelay: (idx) => idx * 30 |
hitBar() |
tooltip.trigger: 'item' 的命中内核 |
drawTooltip() |
tooltip 组件 |
明天写折线图——重点是最近点命中(鼠标在任意位置都能找到最近的数据点),那是 ECharts axisPointer 的灵魂。