canvas绘制时间热力图

1,750 阅读5分钟

canvas绘制时间热力图

效果展示

codepen上查看源码

结构思考

看起来有些复杂的时间热力图。

圈由内到外7层,分别代表周一到周日,圈从12点钟方向开始,代表00:00,顺时针一圈,分别代表一天24小时。

而对应的颜色深浅代表当前对应的时间内,数据的大小。

一方面要考虑到如何简单的绘制,查询相应的API。另一方面要确保一个色块和一个数据如何对上。

综上,整理出绘图思路

  1. 先绘制色块
    • 色块绘制API:void ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);
    • 通过设置较大的线宽,绘制较小的角度,从而形成一个色块
    • 绘制色块通过2层for循环,第一层for中变换角度,从而绘制出一圈,第二层for变换radius,准确的说是半径从而绘制出内圈。
    • 两层for循环的好处:for循环中的ij可以进行数据映射,根据数据的大小,显示颜色的深浅。
  2. 再绘制文字
    • 注意文字要用到旋转,canvas旋转API需要进行ctx.save()ctx.restore()来保存状态。

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来返回上一个状态,从而进行复杂的状态控制。

codepen上查看源码