文章目录
- 前言
- 这页案例的重点是什么
- 完整代码
- `data` 和 `labels` 是图表的数据骨架
- 为什么一定要先留 `padding`
- 坐标轴和基线怎么画
- 网格线为什么很关键
- 柱宽和间距怎么分配
- 柱子的高度怎么从数据映射出来
- 顶部数值和底部标签为什么值得保留
- 颜色数组为什么也值得单独设计
- 这个案例怎么改成业务图表
- 常见坑提醒
- 写在最后
前言
一提到数据可视化,很多人第一反应就是找图表库。这个思路没问题,但有些场景下,你未必需要一整套库。比如只是做一个轻量周报图、设备状态面板、运营数据卡片,自己用Canvas画一个简单柱状图,反而更灵活,依赖也更轻。
问题是,图表跟普通图形不一样。它不是“画几个柱子”就完了,还涉及坐标区域、留白、标签、刻度、数值位置。这也是为什么不少人一开始觉得图表很难。
这个案例正好给了一个很好的起点:手写一张最基础的柱状图。
这页案例的重点是什么
它不是要做一个专业图表库,而是把最基础的数据图表达清楚。里面已经包含了柱状图最关键的几块结构:
- 数据数组和标签数组
- 画布内边距
- Y 轴和底部基线
- 网格线与刻度文字
- 柱子本身
- 顶部数值和底部标签
你把这些关系弄明白,以后换成折线图、面积图、进度图,思路都能迁移。
完整代码
import{DEMO_BG_COLOR,DEMO_CARD_COLOR,DEMO_THEME_COLOR,DEMO_SUBTEXT_COLOR}from'./types'interfacePaddingConfig{top:numberbottom:numberleft:numberright:number}@Entry@Componentstruct CanvasBarChartBasics{@StateisShow:boolean=trueprivatesettings:RenderingContextSettings=newRenderingContextSettings(true)privatecontext:CanvasRenderingContext2D=newCanvasRenderingContext2D(this.settings)privatedata:number[]=[65,80,45,90,55,75,60]privatelabels:string[]=['周一','周二','周三','周四','周五','周六','周日']build(){Column(){if(this.isShow){Scroll(){Column({space:16}){Text('Canvas 柱状图').fontSize(16).fontColor(DEMO_THEME_COLOR).fontWeight(FontWeight.Bold)Column(){Text('每周数据统计').fontSize(13).fontColor('#4D96FF').margin({bottom:8})Column(){Canvas(this.context).onReady(()=>{constw=380consth=220constpadding:PaddingConfig={top:20,bottom:40,left:40,right:20}constchartW=w-padding.left-padding.rightconstchartH=h-padding.top-padding.bottomconstbarW=chartW/this.data.length*0.6constgap=chartW/this.data.length*0.4this.context.clearRect(0,0,w,h)this.context.strokeStyle='#E0E0E0'this.context.lineWidth=1this.context.beginPath()this.context.moveTo(padding.left,padding.top)this.context.lineTo(padding.left,padding.top+chartH)this.context.lineTo(w-padding.right,padding.top+chartH)this.context.stroke()for(leti=0;i<=4;i++){consty=padding.top+chartH*(1-i/4)this.context.strokeStyle='#F0F0F0'this.context.beginPath()this.context.moveTo(padding.left,y)this.context.lineTo(w-padding.right,y)this.context.stroke()this.context.font='10px sans-serif'this.context.fillStyle='#AAAAAA'this.context.fillText(''+25*i,padding.left-30,y+3)}constcolors=['#4D96FF','#6BCB77','#FFD93D','#FF6B6B','#9B59B6','#FFA500','#4ECDC4']for(leti=0;i<this.data.length;i++){constx=padding.left+(chartW/this.data.length)*i+gap/2constbarH=(this.data[i]/100)*chartHconsty=padding.top+chartH-barHthis.context.fillStyle=colors[i]this.context.fillRect(x,y,barW,barH)this.context.font='10px sans-serif'this.context.fillStyle='#666666'this.context.textAlign='center'this.context.fillText(''+this.data[i],x+barW/2,y-5)this.context.fillText(this.labels[i],x+barW/2,padding.top+chartH+18)}}).width('100%').height(240).backgroundColor('#FAFAFA').borderRadius(8)}}.backgroundColor(DEMO_CARD_COLOR).padding(12).borderRadius(12)Column({space:6}){Text('数据详情').fontSize(14).fontColor(DEMO_THEME_COLOR).fontWeight(FontWeight.Bold)Row(){ForEach(this.labels,(label:string,_:number)=>{Text(label).fontSize(11).fontColor('#666666').layoutWeight(1).textAlign(TextAlign.Center)})}.width('100%')Row(){ForEach(this.data,(val:number,_:number)=>{Text(''+val).fontSize(13).fontColor('#1A1A1A').fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center)})}.width('100%')}.width('100%').backgroundColor('#E8F4FD').padding(14).borderRadius(12)}.width('100%')}}Text('CanvasBarChartBasics - 绘制每周柱状图').fontSize(12).fontColor(DEMO_SUBTEXT_COLOR).margin({top:12})}.width('100%').height('100%').backgroundColor(DEMO_BG_COLOR).padding(16)}}data和labels是图表的数据骨架
案例先定义了两组数组:
privatedata:number[]=[65,80,45,90,55,75,60]privatelabels:string[]=['周一','周二','周三','周四','周五','周六','周日']这就是图表最基本的输入。
data决定柱子的高度,labels决定底部每根柱子对应的说明。很多人一开始就急着画,其实真正应该先想清楚的是:数据结构长什么样。
如果输入结构乱,后面坐标、标签、颜色都会跟着乱。
为什么一定要先留padding
这句是全篇里最容易被低估的代码:
constpadding:PaddingConfig={top:20,bottom:40,left:40,right:20}图表不是填满整块画布就完事。你必须给坐标轴、刻度、标签、顶部数值留空间。
所以这里把画布总尺寸w、h再减去内边距,得到真正的图表可用区域:
constchartW=w-padding.left-padding.rightconstchartH=h-padding.top-padding.bottom这个思路特别重要。以后你做任何可视化,几乎都要先从“总空间”和“绘图区”拆开。
坐标轴和基线怎么画
案例用路径画了左侧轴线和底部基线:
this.context.beginPath()this.context.moveTo(padding.left,padding.top)this.context.lineTo(padding.left,padding.top+chartH)this.context.lineTo(w-padding.right,padding.top+chartH)this.context.stroke()这里其实把图表的边界感一下子立起来了。即使你还没画柱子,用户也能感觉到“这是个数据区域”。
很多轻量图表不一定要完整包围框,但底线和一条纵向参考轴通常很有必要。
网格线为什么很关键
案例里用循环画了五条水平网格线:
for(leti=0;i<=4;i++){consty=padding.top+chartH*(1-i/4)this.context.moveTo(padding.left,y)this.context.lineTo(w-padding.right,y)}它的作用不是装饰,而是帮助读数。
没有网格线时,用户只能看出哪根柱子高、哪根柱子低;有了刻度线和左侧数值,他才更容易估出这是 50、75 还是接近 100。
数据图的本质不是“好看”,而是“能读”。
柱宽和间距怎么分配
案例把每一列的总空间拆成两部分:
constbarW=chartW/this.data.length*0.6constgap=chartW/this.data.length*0.4这是一种非常实用的比例分配法。每组位置 60% 给柱子,40% 给空隙。这样柱子不会挤在一起,也不会显得太散。
这类写法的好处是,数据项数量变了,整体仍然能保持相对平衡。
柱子的高度怎么从数据映射出来
真正的数据映射就在这两句:
constbarH=(this.data[i]/100)*chartHconsty=padding.top+chartH-barH第一句把数据值映射成柱高。
第二句决定柱子从哪里开始画。因为Canvas的y轴是向下增长的,所以柱子要想“从底部往上长”,就必须先算出顶部坐标。
这一步是很多人最容易算反的地方。
顶部数值和底部标签为什么值得保留
案例里每根柱子都画了两个文字:
this.context.fillText(''+this.data[i],x+barW/2,y-5)this.context.fillText(this.labels[i],x+barW/2,padding.top+chartH+18)这两个信息点其实很关键。
顶部数值让用户不必用眼睛估计;底部标签让用户知道这一列到底代表哪一天。它们一上,图表就从“可视块”变成了“可阅读的数据表达”。
颜色数组为什么也值得单独设计
案例给每根柱子配了不同颜色:
constcolors=['#4D96FF','#6BCB77','#FFD93D','#FF6B6B','#9B59B6','#FFA500','#4ECDC4']这适合演示效果,但真实业务里要看场景。
如果强调每一天是独立分类,多色可以;如果强调同一维度的连续数据,统一色系往往更专业。比如一组销售额柱子通常不必七种颜色全上。
这个案例怎么改成业务图表
稍微改一下,这页就能变成很多业务组件。
比如:
- 把
labels换成设备编号,做设备监测面板 - 把
labels换成月份,做月度趋势卡片 - 把
data接成接口返回值,做真实报表模块 - 把颜色策略改成阈值色,做异常预警图
也就是说,这个案例已经不是玩具了,它其实是个可复用的最小图表骨架。
常见坑提醒
第一个坑,是没有先减掉padding,结果标签和柱子全贴边。
第二个坑,是textAlign改成center后忘了恢复,不过这个案例后续没有再写别的内容,所以暂时没出问题。
第三个坑,是数据最大值写死为 100。如果真实数据不是这个量级,需要先做动态最大值计算。
第四个坑,是直接把太多标签塞到底部,文字会重叠。数据一多,布局策略要变。
写在最后
柱状图这件事,看起来像是图形问题,实际上更像空间和数据关系问题。只要你把绘图区、比例映射、标签位置和信息层级想清楚,用Canvas画一张清爽的图并不难。
这一页特别值得反复练,因为它已经把Canvas从“画图”推进到了“表达数据”。这一步走通,后面的应用场景会一下子宽很多。