canvas实现睡眠波
更新说明
- 已对该项目做了升级,并发了npm插件,新版本请跳转
成果
- 产品借鉴(抄袭)了华为运动健康App上对用户睡眠数据的展示,要我们也实现这种效果。App开发的同事虽然做出了一点样子,但是有点小丑,担子落到了web的头上(虽然笔者实现的效果也没有华为的好,但是还看的过去)
分析与实现
-
图形
- 图形有点折线图和柱状图结合的意思,但是两者都不是,作为web和小程序开发,最常用的echarts在这时候好像很难起到作用,至少笔者和同事研究了半天用echarts写不出来。然后网上其实没有可以参照的写法,只能自己研究。最终决定用原生canvas一点点画出来。
- 先拆分图形,可以拆分成多个带圆角的方形,和前后两个方形之间上下有两个带弧度的旗帜的线
- 而中间的线有几种情况没有,第一个,最后一个,或者连续两块一样高
- 方形的颜色是自己的,线的颜色是前后两个关联方形的渐变色,那就可以开始画图了
- 借鉴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>