【图表+ECharts】day50-handwritten-barchart

作者:mario 发布时间: 2026-09-02 阅读量:5 评论数:0

Day 50 · 手写柱状图 — 比例尺、刻度算法、动画,把"图表"拆成数学

ECharts 一个 type: 'bar' 背后发生的事:计算坐标轴范围 → 生成"好看"的刻度 → 把数据映射到像素 → 绘制矩形 → 播放入场动画。今天全部自己写一遍。写完你会发现:图表不是"画图",是"数学 + 绘图 + 动画"的三明治


目录


一、图表解剖学:所有图表共享同一副骨架

打开任何一张统计图(柱状/折线/散点),都能拆出同样四块:

┌─────────────────────────────────────────────┐
│                  标题区 (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)时线性轴会把小值压扁。实现 logScalepx = 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 的灵魂。

评论