Canvas绘图组件实战:柱状图饼图的坐标换算与命中检测
简介面向 Web 前端与数据可视化初学者的 JS 绘图组件基于 jscharts 实现主要用于柱状图、饼图等常见统计图表的快速绘制与交互配置适用于报表展示、数据监控页面以及需要动态图表的 Web 项目。资源共含 51 个文件压缩后约 286KB其中 jscharts.js 为核心逻辑27 个 HTML 示例页演示不同场景下的图表写法12 张 JPG 截图辅助查看效果9 个 XML 文件用于定义数据配置另附 JScharts.pdf 文档与 readme 说明分别介绍 API 用法、安装步骤及常见问题。目前已有 171 人学习适合初中级开发者在实际项目中快速接入图表组件减少重复造轮子。examples 目录分列 bar-charts、pie-charts、line-charts 等主题可对照学习数据绑定、多轴设置、扇区标签、动画过渡、点击悬停等要点sources 目录保留完整源码便于二次开发与原理剖析。整体上是一份轻量实用的前端图表学习参考能帮助开发者高效实现柱状图与饼图的定制化展示。1. 这个 js 绘图组件到底能干什么柱状图、饼图之外还要抓住绘制链路很多入行前端做柱状图、饼图的第一反应是去 echarts 里找 option。但 echarts 也是个黑匣子你想改柱子圆角、想让 hover 状态跟业务绑定、想接入自己的动画循环都会被配置项卡住。我拆过的这个 js 绘图组件不依赖任何图表框架用 Canvas 从零实现柱状图和饼图源码把坐标换算、路径绘制、事件命中检测三条主线完整摊开。它更适合你哪怕只是想把图拿到自己的项目里快速改一版也能直接从源码里抽出一段逻辑来用。组件自带完整示例和工具函数前端基础过关就能照着跑通后端或全栈想接触可视化渲染也可以拿它当第一个练手项目。2. 渲染底层与坐标系转换为什么用 Canvas、bandScale 怎么算2.1 Canvas 还是 SVG数据量、清晰度和事件响应的取舍先说结论这个组件的主绘图层走 Canvas标签与提示框用 HTML 叠加。原因只有一个——数据量。用 SVG 画柱状图一个柱子就是一个rect节点。数据一到 1000 条页面就得挂几千个节点滚动、缩放都跟着放大开销。Canvas 没有节点画笔只画图绘图完成就是一张位图DOM 数量和图形复杂程度无关。这对饼图同样适用扇区路径直接画在画布上所有扇区共用同一个ctx对象。但 Canvas 的麻烦在事件。SVG 自带 shape 的鼠标事件Canvas 里只有一个 canvas 元素鼠标位置要自己算。如果只是画个图不交互两者都能用你要是想让柱子支持 hover、点击、联动Canvas 命中检测就是必须自己完成的功课。这个组件的 hitTest 模块写得很克制后面会展开。从维护角度看如果你的项目后续还加折线图、雷达图SVG 和 Canvas 混用会很割裂。统一到 Canvas绘制逻辑共用一套比例尺、一个重绘入口扩展成本最低。下面这张表是我平时做选型的判断依据维度SVGCanvas数据量 ≤ 200推荐方便事件捕获可用数据量 500DOM 压力大卡顿明显推荐高频交互依赖 DOM 事件代码分散用 hitTest 集中处理高清屏适配矢量无压力需要 devicePixelRatio 修正注意选用 Canvas 后清晰度不再是 SVG 的矢量级而是要配合 devicePixelRatio 做位图缩放这部分细节写在避坑章节里。2.2 连续值的线性映射与刻度从数据到像素的正反映射Canvas 和 SVG 一样都建立在“把数据坐标映射到像素坐标”之上。柱状图的 y 轴、饼图的扇形角度本质都是比例尺。写个通用函数很简单但我一般会要求它同时支持正向和反向映射——正向画图反向拿鼠标坐标反查数据一举两得。function createScale(domain, range) { const [d0, d1] domain; const [r0, r1] range; const length d1 - d0 || 1; return { // 数据值 - 像素 toPixel(value) { return r0 ((value - d0) / length) * (r1 - r0); }, // 像素 - 数据值用于鼠标命中 toValue(pixel) { return d0 ((pixel - r0) / (r1 - r0)) * length; } }; }domain是数据边界比如[0, 100]range是像素边界。柱状图的 y 轴 range 一定写成[chartHeight, 0]因为 Canvas 的 y 坐标向下增长而数值向上增长两者方向反了柱子会倒着画。length用|| 1兜底防止数据源全相等时出现除零。刻度生成比想象中容易出错。直接对 min 到 max 做等分会出现0.30000000000000004这类浮点小数如果按最大值和最小值自己除步长又不美观。我习惯抽一个niceTicks函数function niceTicks(min, max, maxTicks 5) { const span max - min; if (span 0) return [min, max]; const roughStep span / maxTicks; const magnitude Math.pow(10, Math.floor(Math.log10(roughStep))); const normalized roughStep / magnitude; let step; if (normalized 1) step 1; else if (normalized 2) step 2; else if (normalized 2.5) step 2.5; else if (normalized 5) step 5; else step 10; step * magnitude; const start Math.floor(min / step) * step; const end Math.ceil(max / step) * step; const ticks []; for (let t start; t end; t step) ticks.push(t); return ticks; }这里选 1/2/2.5/5/10 几个档位是因为人眼对“1、2、2.5、5”的间隔比“3、7”更友好。magnitude用来缩放数据量级比如数据是 0.30.9步长就是 0.1 而不是 1数据是 300900步长就是 100。算出start和end后循环累加同样要注意浮点误差很多组件就是坑在刻度小数位导致网格线对不齐。2.3 分类轴的 bandScale柱状图的 x 轴不是线性映射柱状图的 x 轴和 y 轴不一样。y 轴值是连续数字x 轴是“一月、二月、三月”这类分类名。如果把分类名直接传给createScale根本没法算。常见做法是用 band scale把整个宽度按分类数量切成等宽区间再给每个柱子让出一部分“带宽”。function createBandScale(domain, range) { const [r0, r1] range; const count domain.length; if (count 0) return () 0; const step (r1 - r0) / count; const bandWidth step * 0.8; // 柱子占区间宽度的 80% const offset (step - bandWidth) / 2; return { getBandWidth() { return bandWidth; }, getX(index) { return r0 index * step offset; } }; }参数说明domain是分类数组range是 x 轴的像素左右边界。step是每个分类占用的区间宽度bandWidth是柱子实际宽度剩下 20% 作为左右留白。offset让柱子在该区间居中避免柱子贴边。这样 x 轴就不需要逐个判断“第几个分类的像素中心在哪”直接收索引取值就行。3. 柱状图实现柱宽自适应、渐变色与 hover 命中的完整链路3.1 柱宽与间隙怎么布局数据一多不能糊成一团柱状图第一步是布局。布局不是简单chartWidth / count还要考虑左侧文字、右侧空位、柱子之间的间隙。把布局参数化成函数后期给 echarts 这种图表库做适配也方便。function layoutBarRects(data, chartWidth, chartHeight, options {}) { const { padding 20, gapRatio 0.2, leftPad 10, } options; const count data.length; const contentWidth chartWidth - padding - leftPad; const gap Math.max(2, Math.floor((contentWidth * gapRatio) / Math.max(1, count - 1))); const barWidth Math.floor((contentWidth - gap * (count - 1)) / count); const rects data.map((value, i) ({ value, x: padding i * (barWidth gap), width: barWidth, })); return { rects, barWidth, gap }; }参数说明gapRatio表示间隙占整个内容宽度的比例柱子越多这个比例下的间隙会自动缩小但Math.max(2, ...)保证间隙不会小于 2 像素leftPad预留 y 轴刻度的空间。返回的rects还没写高度因为高度依赖比例尺要在拿到 max/min 之后再计算。当barWidth小于 1 像素时柱子太细已经没意义。常见做法有两个一是横向滚动容器二是先做聚合或抽样。我倾向于聚合因为 Canvas 虽然不怕节点多但一像素宽的柱子很难看出趋势。绘制时统一把 y 和 height 附上去rects.forEach((rect) { const y scale.toPixel(rect.value); rect.y y; rect.height baseY - y; });3.2 渐变色与 hover 状态方向、高亮和事件坐标渐变色是 echarts 用户最喜欢调但最常写错的地方。Canvas 的渐变跟 CSS 不同需要给createLinearGradient两个点表示渐变轴的方向。柱子一般从底到顶渐变所以起点取(0, baseY)终点取(0, topY)。function drawBar(ctx, rect, { baseY, colorStart, colorEnd }) { const gradient ctx.createLinearGradient(0, baseY, 0, rect.y); gradient.addColorStop(0, colorStart); gradient.addColorStop(1, colorEnd); ctx.fillStyle gradient; ctx.fillRect(rect.x, rect.y, rect.width, baseY - rect.y); }渐变轴的起点是 baseY底部终点是柱子顶部。数值越小柱子越短渐变区间越小这是正常现象。如果希望颜色按整个绘图区域高度渐变而不随柱子高度变化需要把终点固定为scale.toPixel(min)这样所有柱子的渐变比例才一致视觉上更统一。hover 高亮和点击命中用的是一套坐标反查。在 Canvas 上监听mousemove拿到clientX/clientY后先减去画布偏移再从下往上找第一个命中的柱子canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; let hitIndex -1; for (let i bars.length - 1; i 0; i--) { const bar bars[i]; if (mx bar.x mx bar.x bar.width my bar.y my bar.y bar.height) { hitIndex i; break; } } if (hitIndex ! lastHoverIndex) { lastHoverIndex hitIndex; render(); } });这里有两个细节。第一遍历顺序应该从最近的柱子开始因为图形后面被挡住的柱子不应该优先响应鼠标。柱状图没有重叠时正序也没问题但写成倒序更稳妥。第二render里要根据lastHoverIndex重设每个柱子的颜色比如 hover 柱子用亮色其他柱子加 30% 透明。这就是“鼠标点哪儿哪儿高亮”的效果这套交互逻辑给饼图同样能用。3.3 基线、数值标签和坐标轴的绘制柱子画完不能没有参考线。y 轴基线要画在最底部不然柱子悬浮在空中。具体做法是在baseY处画一条 1px 的横线颜色用rgba(0,0,0,0.15)如果直接画纯黑会抢了数据的视觉权重。数值标签放在柱顶上方但要注意柱子太矮或太高的情况const labelY rect.y - 6; if (labelY 10) labelY rect.y rect.height 16;这样 label 就不会溢出画布顶部。ctx.textAlign设为center基线设为bottom可以让文字相对柱顶对齐。坐标轴刻度文字不用每个都画maxTicks控制在 5 个左右防止刻度和柱子挤在一起。4. 饼图实现扇形几何、标签避让与中心挖孔4.1 扇区绘制为什么从 12 点方向开始累加角度饼图的数据处理比柱状图简单但容易出 bug 的地方在角度累加。默认的ctx.arc从 0 弧度开始0 弧度是三点钟方向如果你直接把第一个百分比除以 360 当作弧度画第一个扇区会从右侧开始视觉上不习惯。所以绘图前会把起始角固定在-Math.PI / 2也就是 12 点钟方向再顺时针累加。function drawPieSlices(ctx, { cx, cy, r, data, colors }) { const total data.reduce((sum, d) sum d.value, 0); if (total 0) return; let currentAngle -Math.PI / 2; data.forEach((item, index) { const ratio item.value / total; const angle ratio * Math.PI * 2; const endAngle currentAngle angle; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, currentAngle, endAngle); ctx.closePath(); ctx.fillStyle colors[index % colors.length]; ctx.fill(); currentAngle endAngle; }); }代码里moveTo到圆心再arc到指定角度最后closePath形成一个封闭扇形。ctx.arc默认按顺时针走刚好和饼图习惯一致。每个扇区画完后要把currentAngle更新为endAngle保证下一个扇区接上。如果所有数据相加为 0函数直接 return不做无意义绘制。做环形图时不要用“外圈画圆再填充背景色”的思路那样很难处理 hover 的边界。一个更好的方法是直接画环形扇区ctx.beginPath(); ctx.arc(cx, cy, rOuter, start, end); // 外圈顺时针 ctx.arc(cx, cy, rInner, end, start, true); // 内圈逆时针 ctx.closePath(); ctx.fill();两次arc分别沿外弧和内弧走形成一个带厚度的环形路径。第二次arc的最后一个参数是true表示逆时针方向反了整个环会变成奇怪的翻转形状。如果你需要挖一块中间区域放文字路径方式比填充颜色方式灵活得多。4.2 标签线与百分比排版文字放不下时的避让策略百分比文字不能直接画在扇区中心窄扇区的中心点在小角度下会跑到别的扇区去。一般做法是把文字放在半径外沿用一条短折线连接扇区和文字。折线的起点是扇区中心角对应圆边上的点function getLabelAnchor(cx, cy, r, angle) { return { x: cx Math.cos(angle) * r, y: cy Math.sin(angle) * r }; }这里angle取扇区的中间角度(currentAngle endAngle) / 2。计算完锚点后要注意文字对齐方向锚点在画布左边时textAlign设为right同时 x 再往左偏移 6px锚点在右边则用left对齐。如果不分左右统一用居中标签会骑在线上读者看着很难受。当扇区角度很小标签互相之间会重叠。我一般给标签列表按 y 坐标从小到大排一次序然后再做邻接校正labels.sort((a, b) a.y - b.y); for (let i 1; i labels.length; i) { const prev labels[i - 1]; const current labels[i]; if (current.y prev.y LABEL_HEIGHT) { current.y prev.y LABEL_HEIGHT; } }这样一排标签从上往下彼此不撞相邻扇区的 label 也不会叠在一起。代价是标签线和扇区锚点之间可能变成斜线这是饼图常见的取舍。强制所有标签都贴紧扇区会把图憋得很难看。4.3 扇区 hover 命中根据距离和角度反查扇区Canvas 上的饼图没有路径对象hover 检查可以用几何方法先判断鼠标点是否在圆内再计算鼠标点相对圆心的角度最后累加角度找对应扇区。function hitTestSlice(mx, my, cx, cy, r, data) { const dx mx - cx; const dy my - cy; const dist Math.sqrt(dx * dx dy * dy); if (dist r) return -1; let angle Math.atan2(dy, dx); if (angle -Math.PI / 2) angle Math.PI * 2; let cumulative -Math.PI / 2; for (let i 0; i data.length; i) { const sliceAngle (data[i].value / total) * Math.PI * 2; if (angle cumulative angle cumulative sliceAngle) return i; cumulative sliceAngle; } return -1; }Math.atan2返回的角度范围是-PI到PI而我们的起始角是-PI/2所以需要把左侧区域的角度加上2*PI转换成-PI/2到3*PI/2的闭区间。判断完成后再用cumulative累加定位性能也不差几千个扇区也够跑。5. 避坑记录绘图组件最容易翻车的 5 类事故5.1 高清屏模糊没有乘 devicePixelRatio现象在 MacBook 或手机浏览器上柱状图文字发虚柱子边缘像水彩晕开hover 命中的位置还总偏移。原因Canvas 默认canvas.width是 CSS 像素值。高分屏一个 CSS 像素对应多个物理像素不放大绘图区再缩放回来就会被浏览器拉伸边缘自然模糊。解决const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);注意setTransform必须在每次canvas.width重新赋值之后调用因为重新设置 width 会重置 canvas 的变换矩阵。设置之后坐标体系和 CSS 像素保持一致鼠标坐标不用再手动乘 dpr。5.2 数据全相等时柱状图消失或崩溃现象后端返回的指标全是同一个值柱状图把所有柱子画成一个平面甚至直接报NaN。原因比例尺toPixel里d1 - d0为 0length提前用|| 1兜底了还好但很多源码没有做这一步除零后像素坐标全变成NaN柱子直接画不出来。解决绘制前对 min/max 做保护let min Math.min(...values); let max Math.max(...values); if (min max) { min - 1; max 1; }这是常见做法不影响真实数据。比如数值都是 80则 range 变成 7981图中柱子依然有可见高度信息也不会失真。5.3 resize 后事件坐标错位现象浏览器窗口拉宽后柱状图的 hover 高亮总是高亮错误的柱子越靠近画布右侧偏差越大。原因鼠标事件里e.clientX - canvas.getBoundingClientRect().left没有缓存resize 后没有重新读取或者重新赋值canvas.width后没有重置setTransform。解决function resize(cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); bindRect(); scheduleRender(); }bindRect里把getBoundingClientRect()的结果存成变量事件处理里直接用缓存减少重复读取。绑定 scroll 事件时也要更新 rect因为页面滚动会导致 canvas 相对视口的位置变化。5.4 频繁 setData 导致动画帧堆积现象组件接入实时数据数据每几百毫秒更新一次一段时间后突然卡顿图表闪烁。原因setData里直接调用render()如果渲染耗时超过更新间隔多个渲染请求重叠前一帧还没画完后一帧又入队。解决把渲染统一收敛到 requestAnimationFrameclass Chart { scheduleRender() { if (this._rafId) return; this._rafId requestAnimationFrame(() { this._rafId 0; this.render(); }); } }如果已经有计划帧直接忽略新的调度等下一帧统一重绘。这样能把短时间内的多次数据更新合并成一次渲染性能会稳定很多。5.5 Canvas 渐变对象重复创建导致颜色异常现象数据更新后页面内存持续上涨重复 hover 后柱子颜色越画越暗。原因createLinearGradient每次刷新都会新建一个 CanvasGradient 对象频繁创建又不释放大面积绘制时出现隐式状态污染。解决在数据没变化时缓存渐变对象。我一般给drawBar传入一个 cache 对象key 是柱子索引加渐变方向只有颜色或高度变化时才重建。饼图和渐变圆弧同理复用同一份渐变对象能明显降低重绘开销。6. 进阶把柱状图和饼图收敛成一个 Chart 基类顺便验证刻度对齐当单个图跑通后下一步就是把绘制逻辑收敛成组件。这个 js 绘图组件源码里提供的 Chart 基类核心是让柱状图、饼图共用数据入口和重绘通道只暴露type接口。你可以在自己的项目里照抄这个骨架class Chart { constructor(canvas, options {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.options Object.assign({ type: bar, padding: 20 }, options); this.data this.options.data || []; this._rafId 0; this.scheduleRender(); } setData(data) { this.data data; this.scheduleRender(); } scheduleRender() { if (this._rafId) return; this._rafId requestAnimationFrame(() { this._rafId 0; this.render(); }); } render() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); if (this.options.type bar) this.renderBar(); else if (this.options.type pie) this.renderPie(); } }这样做的价值在于把数据更新、渲染调度、绘制分离开。之后你往组件里加折线图、雷达图时不需要改setData和事件绑定只需要加一个renderLine分支。画布尺寸初始化、devicePixelRatio 适配、resize 事件也都统一在这个基类里做。顺带说一个最简单的验证方法在 render 入口画一层调试网格把刻度线画出来对比柱子的底部和格子是否落在同一条线上。if (this.options.debug) { this.ctx.strokeStyle rgba(0,0,0,0.1); ticks.forEach((tick) { const y scale.toPixel(tick); this.ctx.beginPath(); this.ctx.moveTo(0, y); this.ctx.lineTo(this.canvas.width, y); this.ctx.stroke(); }); }这层网格只花几微秒但能把比例尺、柱子高度、标签位置一次校验完。遇到 hover 偏位也能第一时间判断是事件坐标的问题还是比例尺的问题。从那以后我每次写图表组件都强制自己先跑一遍空数据、全等值、高分屏三个场景再去做交互。希望帮到你。本文还有配套的精品资源点击获取