canvas实现睡眠波

更新说明

  • 已对该项目做了升级,并发了npm插件,新版本请跳转

成果

res

  • 产品借鉴(抄袭)了华为运动健康App上对用户睡眠数据的展示,要我们也实现这种效果。App开发的同事虽然做出了一点样子,但是有点小丑,担子落到了web的头上(虽然笔者实现的效果也没有华为的好,但是还看的过去)

分析与实现

  • 图形

    • 图形有点折线图和柱状图结合的意思,但是两者都不是,作为web和小程序开发,最常用的echarts在这时候好像很难起到作用,至少笔者和同事研究了半天用echarts写不出来。然后网上其实没有可以参照的写法,只能自己研究。最终决定用原生canvas一点点画出来。
    • 先拆分图形,可以拆分成多个带圆角的方形,和前后两个方形之间上下有两个带弧度的旗帜的线
      eg
    • 而中间的线有几种情况没有,第一个,最后一个,或者连续两块一样高
    • 方形的颜色是自己的,线的颜色是前后两个关联方形的渐变色,那就可以开始画图了
    • 借鉴echarts,封装成可自定义配置,且用dom来初始化的形式
    // sleepWave.js
    import Vue from 'vue'
    import Tooltip from './canvas_tooltip.vue'
    import { SleepWaveBlock } from './sleep_block'
    import { throttle } from 'lodash'
    
    export class SleepWave {
      canvas = null
      ctx = null
      eventList = ['click', 'mousemove']
      children = [] // 存放子元素
    
      sleepType = ['深睡', '浅睡', '眼动', '清醒']
      sleepColor = ['#FFB9E4', '#D3B4FF', '#9345FF', '#5701CD']
      sleepData = []
    
      axisColor = '#EAEAEA'
      axisYwidth = 50 // Y轴的宽度
      axisXheight = 20 // X轴的高度
      Xmax = 800 // 时间最大值s
      Xmin = 0 // 时间最小值s
      Xdis = 1 // 单位长度x表示的时间
      Ydis = 1 // 单位y的高度
    
      borderRadius = 12 // 方形弧度
      lineWidth = 2 // 连线宽度
    
      // 初始化
      init (dom) {
        dom.style.position = 'relative'
        this.canvas = document.createElement('canvas')
        this.canvas.width = dom.clientWidth
        this.canvas.height = dom.clientHeight
        dom.appendChild(this.canvas)
        const tooltip = document.createElement('div')
        dom.appendChild(tooltip)
        this.tooltip = this.initTooltip().$mount(tooltip)
        this.ctx = this.canvas.getContext('2d')
      }
    
      // 设置option
      setOption (option) {
        // 清空整个画板
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
        // 自定义属性
        Object.assign(this, option)
        // 更新相关属性
        this.updateProperties()
        // 开始绘制
        this.draw()
        // 初始化事件
        this.initEvent()
      }
    
      // 更新相关属性
      updateProperties () {
        this.Xdis = (this.canvas.width - this.axisYwidth) / (this.Xmax - this.Xmin)
        this.Ydis = (this.canvas.height - this.axisXheight) / 4
        // borderRadius最大为块高度的一半也就是绘图区高度的1/8
    	  this.borderRadius = Math.min(this.Ydis / 4, this.borderRadius)
      }
    
      // canvas事件
      initEvent () {
        this.eventList.forEach(eventName => {
          // 高频率事件需要防抖
          this.canvas.addEventListener(eventName, throttle(this.handleEvent, 50))
        })
      }
    
      handleEvent = (event) => {
        this.children
        .filter(shape => shape.isEventInRegion(event.x, event.y))
        .forEach(shape => shape.emit(event.type, event))
      }
    
      initTooltip () {
        return new Vue({
          data: () => {
            return {
              visible: false,
              pos: { x: 0, y: 0 }, // canvas相对左上角0,0的坐标
              blockInfo: {} // 当前触发块自己的信息
            }
          },
          render: function (h) {
            return h(Tooltip, {
              props: {
                visible: this.visible,
                pos: this.pos,
                blockInfo: this.blockInfo
              }
            }, null)
          }
        })
      }
    
      // 绘制
      draw () {
        // 绘制坐标
        this.ctx.beginPath()
        this.ctx.lineWidth = 1
        this.ctx.strokeStyle = this.axisColor
        this.ctx.moveTo(this.axisYwidth - 2, 0)
        this.ctx.lineTo(this.axisYwidth - 2, this.canvas.height - this.axisXheight)
        this.ctx.lineTo(this.canvas.width, this.canvas.height - this.axisXheight)
        this.ctx.stroke()
    
        // 绘制Y轴刻度
        this.ctx.font = '14px PingFang SC'
        this.ctx.fillStyle = '#4E596F66'
        this.sleepType.forEach((label, index) => {
          this.ctx.fillText(label, 4, this.Ydis * (index + 0.5))
        })
    
        // 绘制波形
        this.sleepData.reduce((pre, item) => {
          // 先画方形自己
          // 方框四个点位置
          const startX = pre ? pre[0] : this.axisYwidth
          const endX = startX + item[0] * this.Xdis
          const startY = item[1] * this.Ydis + this.borderRadius
          const endY = startY + this.Ydis - 2 * this.borderRadius
          // 表示宽度是否够画直线,如果不够直接连着贝塞尔
          const hasStraight = (endX - startX) > 2 * this.borderRadius
          // 够不够画直线都要计算那个点的位置,X轴差值
          const nowRadius = hasStraight ? this.borderRadius : (endX - startX) / 2
          const { sleepColor, sleepType, borderRadius, lineWidth } = this
    
          // new block块对象
          const block = new SleepWaveBlock(this, {
            startX,
            startY,
            endX,
            endY,
            hasStraight,
            nowRadius,
            color: sleepColor[item[1]],
            title: sleepType[item[1]],
            value: item[0],
            borderRadius,
            lineWidth
          })
          block.draw()
          this.children.push(block)
    
          // 再连线
          if (pre && pre[1] !== item[1]) { // 一样高得情况不画线
            // 需要判断是从下到上还是从上到下,注意canvas Y轴是上小下大,这里的上下是直觉上下
            const dir = pre[1] > item[1] // 0: 一样高(这种情况理论不应该存在),true:从下到上,false:从上到下
            const lineX = startX // 连线X
            const lineStartY = dir ? (pre[1] * this.Ydis + this.borderRadius) : ((pre[1] + 1) * this.Ydis - this.borderRadius) // 连线起点Y
            const lineEndY = dir ? endY : startY// 连线终点Y
            const linearGradient = this.ctx.createLinearGradient(lineX, lineStartY, lineX, lineEndY) // 线的渐变色
            linearGradient.addColorStop(0, this.sleepColor[pre[1]])
            linearGradient.addColorStop(1, this.sleepColor[item[1]])
            this.ctx.beginPath()
            this.ctx.lineWidth = this.lineWidth
            this.ctx.strokeStyle = linearGradient
            this.ctx.moveTo(lineX, lineStartY + (dir ? (-this.borderRadius) : this.borderRadius))
            this.ctx.quadraticCurveTo(lineX, lineStartY, lineX - pre[2], lineStartY)
            this.ctx.quadraticCurveTo(lineX, lineStartY, lineX, lineStartY + (dir ? this.borderRadius : (-this.borderRadius)))
            this.ctx.lineTo(lineX, lineEndY + (dir ? (-this.borderRadius) : this.borderRadius))
            this.ctx.quadraticCurveTo(lineX, lineEndY, lineX + nowRadius, lineEndY)
            this.ctx.quadraticCurveTo(lineX, lineEndY, lineX, lineEndY + (dir ? this.borderRadius : (-this.borderRadius)))
            this.ctx.closePath()
            this.ctx.fillStyle = linearGradient
            this.ctx.fill()
            this.ctx.stroke()
          }
          return [endX, item[1], nowRadius] // 下一个pre为当前点的endx和item[1],注意不是x不是item[0], 这里的第三个是上一次绘图的弧度
        }, null)
      }
    }
    
    • 由于触发Tooltip的元素是方块,线不触发,所以要单独封装管理状态,而且这里就需要引出canvas事件的封装,就是canvas只是一张画布,它是怎么判断当前鼠标虚浮在哪个元素上,怎么判断时间触发,怎么触发事件的
    • 需要先封装一个Event,然后让有触发能力的canvas内部的元素集成Event,然后让它监听它需要监听的事件
    // canvas_event.js
    class Event {
      constructor () {
        this._listener = {}
      }
    
      /**
       * 监听
       */
      on (type, handler) {
        if (!this._listener[type]) {
          this._listener[type] = []
        }
    
        this._listener[type].push(handler)
      }
    
      /**
       *触发
       */
      emit (type, event) {
        if (event == null || event.type == null) {
            return
        }
        const typeListeners = this._listener[type]
        if (!typeListeners) return
        for (let index = 0; index < typeListeners.length; index++) {
          const handler = typeListeners[index]
          handler(event)
        }
      }
    
      /**
       * 删除
       */
      remove (type, handler) {
        if (!handler) {
          this._listener[type] = []
          return
        }
    
        if (this._listener[type]) {
          const listeners = this._listeners[type]
          for (let i = 0, len = listeners.length; i < len; i++) {
              if (listeners[i] === handler) {
                  listeners.splice(i, 1)
              }
          }
        }
      }
    }
    export default Event
    
    • 然后就是怎么使用Event,把需要触发事件的元素也单独封装,这样方便管理事件触发时元素自己的属性。而事件的初始化,就是在Block构造函数里调用Event.on,而鼠标是否在元素上,则通过isEventInRegion判断
    // sleep_block.js
    import Event from './canvas_event'
    
    export class SleepWaveBlock extends Event {
      point = { x: 0, y: 0 }
      constructor (canvas, opts) {
        super()
        this.canvas = canvas
        this.ctx = canvas.ctx
        this.width = this.canvas.canvas.width
        Object.assign(this, opts)
        this.on('click', this.handleClick)
        this.on('mousemove', this.handleMouseMove)
      }
    
      commonHandler = () => {
        document.body.style.cursor = 'pointer'
        this.canvas.tooltip.visible = true
      }
    
      handleClick = (e) => {
        this.commonHandler()
      }
    
      handleMouseMove = (e) => {
        this.commonHandler()
        setTimeout(() => { // tooltip刚显示时还获取不到$el,下一帧再处理数据
          const boxWidth = this.canvas.tooltip.$el.clientWidth
          this.canvas.tooltip.pos = { x: this.point.x + boxWidth + 12 >= this.width ? this.width - boxWidth - 12 : this.point.x, y: 8 }
          this.canvas.tooltip.blockInfo = {
            color: this.color,
            title: this.title,
            value: this.value
          }
        })
      }
    
      draw () {
        const { startX, startY, endX, endY, nowRadius, hasStraight } = this
        this.ctx.beginPath()
        this.ctx.lineWidth = this.lineWidth
        this.ctx.strokeStyle = this.color
        this.ctx.moveTo(startX, startY + this.borderRadius)
        this.ctx.lineTo(startX, endY - this.borderRadius)
        this.ctx.quadraticCurveTo(startX, endY, startX + nowRadius, endY)
        if (hasStraight) { // 是否需要画直线
          this.ctx.lineTo(endX - this.borderRadius, endY)
        }
        this.ctx.quadraticCurveTo(endX, endY, endX, endY - this.borderRadius)
        this.ctx.lineTo(endX, startY + this.borderRadius)
        this.ctx.quadraticCurveTo(endX, startY, endX - nowRadius, startY)
        if (hasStraight) { // 是否需要画直线
          this.ctx.lineTo(startX + this.borderRadius, startY)
        }
        this.ctx.quadraticCurveTo(startX, startY, startX, startY + this.borderRadius)
        this.ctx.stroke()
        this.ctx.fillStyle = this.color
        this.ctx.fill()
      }
    
      // 事件触发的位置是不是当前block位置
      isEventInRegion (clientX, clientY) {
        this.point = this.getEventPosition(clientX, clientY) // 计算基于canvas坐标系的坐标值
        const { x, y } = this.point
        const width = this.endX - this.startX
        const height = this.endY - this.startY
        if (this.startX < x && x < this.startX + width && this.startY < y && y < this.startY + height) {
          return true
        }
        document.body.style.cursor = 'default'
        this.canvas.tooltip.visible = false
        return false
      }
    
      getEventPosition (clientX, clientY) {
        const bbox = this.canvas.canvas.getBoundingClientRect()
        return {
          x: clientX - bbox.left,
          y: clientY - bbox.top
        }
      }
    }
    
  • Tooltip的创建,则是用h函数挂载vue组件,方便组件管理自身状态,又能在js里创建dom,详见initTooltip

  • 绘图的难点主要在于线的绘制,详见draw的画线部分

    // canvas_tooltip.vue
    <template>
      <div class="CanvasTooltip" v-if="visible" :style="{ left: `${this.pos.x}px`, top: `${this.pos.y}px`, borderColor: blockInfo.color }">
        <div class="CanvasTooltip-title">睡眠时长</div>
        <div class="CanvasTooltip-content">
          <span class="CanvasTooltip-point" :style="{ background: blockInfo.color }"></span>
          {{ blockInfo.title }}: <span :style="{ color: blockInfo.color }">{{ blockInfo.value }}</span>
        </div>
      </div>
    </template>
    
    <script>
      export default {
        props: ['visible', 'pos', 'blockInfo']
      }
    </script>
    
    <style lang="less" scoped>
    .CanvasTooltip{
      position: absolute;
      padding: 8px 12px;
      border: 1px solid #EDEDED;
      border-radius: 4px;
      box-shadow: 0 3px 6px #00000029;
      background: #fff;
      pointer-events: none;
      &-title{
        margin-bottom: 4px;
      }
      &-content{
        display: flex;
        align-items: center;
        white-space: nowrap;
      }
      &-point{
        display: inline-block;
        width: 12px;
        height: 12px;
        border-radius: 12px;
        margin-right: 4px;
      }
    }
    </style>
    
    
posted @ 2023-10-09 19:37  Mizuki-Vone  阅读(755)  评论(1编辑  收藏  举报