canvas绘制时间热力图
效果展示
结构思考
看起来有些复杂的时间热力图。
圈由内到外7层,分别代表周一到周日,圈从12点钟方向开始,代表00:00,顺时针一圈,分别代表一天24小时。
而对应的颜色深浅代表当前对应的时间内,数据的大小。
一方面要考虑到如何简单的绘制,查询相应的API。另一方面要确保一个色块和一个数据如何对上。
综上,整理出绘图思路
- 先绘制色块
- 色块绘制API:
void ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise); - 通过设置较大的线宽,绘制较小的角度,从而形成一个色块
- 绘制色块通过2层for循环,第一层for中变换角度,从而绘制出一圈,第二层for变换
radius,准确的说是半径从而绘制出内圈。 - 两层for循环的好处:for循环中的
i和j可以进行数据映射,根据数据的大小,显示颜色的深浅。
- 色块绘制API:
- 再绘制文字
- 注意文字要用到旋转,canvas旋转API需要进行
ctx.save()和ctx.restore()来保存状态。
- 注意文字要用到旋转,canvas旋转API需要进行
canvas的dpr适配
canvas是位图,因此和图片一样,在dpr不等于1的屏幕上,如果图片不使用2倍视觉稿,3倍视觉稿,会出现模糊的问题。做过移动端,c端的同学应该有类似的经验。canvas同样如此。尽管只是在pc端展示,考虑到mac屏幕,依旧需要做dpr适配。
如何做canvas的dpr适配?
假设我们需要在css上展示的大小是400*260,我们在js中可以这么写
var canvas = document.getElementById("timePie")
canvas.height = 260 * dpr
canvas.width = 400 * dpr
css中这么写
#timePie{
height: 260px;
width: 400px;
}
其中canvas.height,canvas.width就好比需要加载图片的高和宽,而css中的height,width则是实际显示图片的高和宽。这样的好处自然就显而易见了,canvas的大小随着dpr变化,然而最后呈现出来的图片的css像素大小是不变的。这样就避免了canvas在高dpr屏幕下的模糊不清。
绘制色块
先封装一个简单的函数,用于绘制较粗的圆弧线条(色块),函数中只有angle变量是全局的,用于累加数据。
function drawArc(x,y,r,color,addValue,lineWidth){
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth; //设置线宽
ctx.beginPath(); //路径开始
ctx.arc(x, y, r ,angle,angle+ addValue, false); //用于绘制圆弧context.arc(x坐标,y坐标,半径,起始角度,终止角度,顺时针/逆时针)
angle += addValue
ctx.stroke(); //绘制
ctx.closePath(); //路径结束
}
然后创建一个随机的数据集:
let timeMap=[]
for(let j=0;j<7;j++){
let newItem = []
for(let i=0;i<24;i++){
newItem.push(Math.random()*100)
}
timeMap.push(newItem)
}
接下来就能通过timeMap[j][i]来访问数据了。
创建一个数据集到颜色的映射函数,接受1个number返回一个颜色的string:
function judgeColor(value){
if(value>75){
return 'rgb(1,94,176)'
} else if(value<=75 && value>50){
return 'rgb(51,160,236)'
} else if(value<=50 && value>25){
return 'rgb(96,183,293)'
} else if(value<=25 && value>=0){
return 'rgb(159,214,244)'
}
}
dpr初始化和设置几个全局的基本参量:
let dpr;
if(window && window.devicePixelRatio){
if(Object.prototype.toString.call(window.devicePixelRatio)==='[object Number]'){
dpr = window.devicePixelRatio
} else{
dpr = 1
}
} else{
dpr = 1
}
基本保证dpr的值是一个数字,且不为0,canvas不会挂。
let canvas = document.getElementById("timePie")
canvas.height = 260 * dpr
canvas.width = 400 * dpr
let canvasXCenter = 200 * dpr
let canvasYCenter = 130 * dpr
let radius = 90 * dpr
let layerWidth = 12 * dpr
let ctx = canvas.getContext('2d');
//初始角度
let angle = Math.PI/12/13 + Math.PI/26
可以看到,所有的常量都随着dpr进行缩放。
准备完毕就可以画图了:
for(let j=0;j<7;j++){
for(let i=0;i<24;i++){
drawArc(canvasXCenter,canvasYCenter,radius-j*layerWidth,judgeColor(timeMap[j][i]),Math.PI/13,10*dpr)
drawArc(canvasXCenter,canvasYCenter,radius-j*layerWidth,"#fff",Math.PI/12/13,10*dpr)
}
}
可以看到半径随着j变化。
这时候就会得到这样一个图片,我们已经成功一半了。
绘制文字
采用APIctx.fillText(),因为涉及到旋转,我们希望每次旋转都以最初水平的状态为原状态。因此采用ctx.save()和ctx.restore()来保持最初的状态。
为什么不直接以上一次的旋转结束的状态作为下一次旋转的初始态?这样每次转动的角度不都是一样的,for24次就好了?
主要是因为文字转24次会倒着显示,图最左边是6pm,旋转180到最右边6am就是反的了。
因此文字的绘制就繁琐一些,多写几行代码来实现了。
先实现周一到周日
ctx.font = `${12 * dpr}px serif`;
ctx.fillStyle = 'rgb(223,236,243)'
for(let i=0;i<7;i++){
ctx.fillText(week[i], canvasXCenter - 10 * dpr, canvasYCenter- 3*dpr - layerWidth *(i+1));
}
再实现0-24小时
ctx.fillStyle = 'rgb(0,0,0)'
for(let i=0;i<7;i++){
ctx.save()
ctx.translate(canvasXCenter,canvasYCenter)
ctx.rotate(Math.PI/12*i)
ctx.fillText(`${6+i}pm`,0 - radius - 35 * dpr, 0 + 3 *dpr )
ctx.restore()
}
for(let i=0;i<5;i++){
ctx.save()
ctx.translate(canvasXCenter,canvasYCenter)
ctx.rotate(-Math.PI/12*(i+1))
ctx.fillText(`${5-i}pm`,0 - radius - 35 * dpr, 0 + 3 *dpr )
ctx.restore()
}
for(let i=0;i<5;i++){
ctx.save()
ctx.translate(canvasXCenter,canvasYCenter)
ctx.rotate(-Math.PI/12*(i+1))
ctx.fillText(`${5-i}am`,0 + radius + 10 * dpr, 0 + 3 *dpr )
ctx.restore()
}
for(let i=0;i<7;i++){
ctx.save()
ctx.translate(canvasXCenter,canvasYCenter)
ctx.rotate(Math.PI/12*i)
ctx.fillText(`${6+i}am`,0 + radius + 10 * dpr, 0 + 3 *dpr )
ctx.restore()
}
结语
canvas2d的设计非常有意思,它的旋转,平移是通过旋转,平移坐标系实现的,同时通过save和restore来返回上一个状态,从而进行复杂的状态控制。