时间轴 - 基于vue2, 可以拖动和滚轮缩放

参考文档:手摸手带你实现一个时间轴组件

说明:实现思路来源于上面的文档,在此基础上做了一些调整,以符合项目需求

完成代码在github上,大家可以自行下载,喜欢的麻烦点个赞

另外也可以去npm直接下载插件

  • 下载插件
    yarn add v-time-shaft
    
  • 全局引入
    import TimerShaft from 'v-time-shaft'
    ......
    Vue.use(TimerShaft)
    
  • 组件中使用
    <template>
      <div id="app">
        <div class="timer-shaft-content">
          <!-- 不限制时间范围,只初始化默认展示哪天的时间 -->
          <v-time-shaft date="2024-05-29"></v-time-shaft>
        </div>
        <div class="timer-shaft-content">
          <!-- 限制时间范围的有用法 -->
          <v-time-shaft isLimit :date="date" :dateRange="dateRange"></v-time-shaft>
        </div>
      </div>
    </template>
    

效果图:

  • 成品效果图
    在这里插入图片描述
  • 放大后,查看时间片段在这里插入图片描述
  • 不限制范围拖动到前一天在这里插入图片描述
  • 时间指示器【鼠标移入,产生的蓝色线条和时间点】在这里插入图片描述

开发环境

node v16.18.0
yarn v1.22.19

vue 模版

事件绑定在canvas的容器上,方便进行canvas分层绘制

<template>
  <div 
    class="canvas-contain"
    ref="canvasContain"
    @mousedown="onMousedown"
    @mouseup="onMouseup"
    @mouseout="onMouseout"
    @mousemove="onMousemove"
    @mousewheel="onMousewheel"
  >
    <canvas ref="canvas"></canvas>
    <canvas class="canvas-line" ref="canvasLine"></canvas>
  </div>
</template>
<style lang="less" scoped>
.canvas-contain {
  width: 100%;
  height: 100%;
  position: relative;
  .canvas-line {
    position: absolute;
    left: 0;
    top: 0;
  }
}
</style>
  1. 一个canvas用于绘制时间轴,一个处理鼠标移动绘制时间指示器
  2. 模版和样式表都非常简单,不做赘述

实现步骤

实现逻辑:在一个缩放等级下,确定用多少根刻度线画出所有对应的时间范围
如:缩放等级下24小时的时间轴,则要在canvas范围内绘制多根刻度线表示24小时

开始开发

初始化

  1. 创建一个init方法
  2. 设置浏览器变化的resize事件
this.init();
window.addEventListener('resize', this.init)
  1. 设置一下画布的宽高及获取画布上下文:
init() {
  this.contain = this.$refs.canvasContain
  if (!this.contain) return
  this.canvas = this.$refs.canvas
  this.canvasLine = this.$refs.canvasLine
  let {width, height} = this.contain.getBoundingClientRect()
  // 缓存width,height
  Object.assign(this, {width, height})
  // 设置canvas width,height
  Object.assign(this.canvas, {width, height})
  Object.assign(this.canvasLine, {width, height})
  // 获取画布上下文
  this.ctx = this.canvas.getContext('2d')
  this.ctxLine = this.canvasLine.getContext('2d')
  // 开始绘制
  this.draw()
},

可传参数说明:
timeSegmentsData
  • Array[object] 时间范围片段数据,
// 基本结构如下:
[{
  id,
  beginTime,          // 片段开始时间,时间戳,单位毫秒
  finishTime,         // 片段结束时间,时间戳,单位毫秒
  active,             // 是否选中状态
  lineWidth,          // 片段边框的线宽
  y,                  // 片段在canvas上距离顶部距离
  h,                  // 片段在canvas上的高度
  bgColor,            // 片段背景颜色
  borderColor,        // 片段边框颜色
  bgColorActive,      // 片段选中状态背景颜色
  borderColorActive,  // 片段选中状态边框颜色
  ...
}]
gridStyles
  • Object 时间刻度样式
// 支持属性:
{
  lineColor,  // 刻度线条颜色
  lineWidth,  // 刻度线条宽度
  lineHeight  // 刻度线条高度
  color,      // 刻度上时间的文字颜色
  dateColor,  // 刻度上的日期文字颜色
  bgColor,    // 刻度条背景颜色
  bgHeight,   // 刻度条背景高度
}
hoverStyles
  • Object 鼠标滑过的时间指示器
// 支持属性:
{
  lineColor,  // 刻度线条颜色
  lineWidth,  // 刻度线条宽度
  lineHeight  // 刻度线条高度
  color,      // 刻度上时间的文字颜色
}
isLimit
  • Boolean 是否限制时间范围
  • 该属性必须与 dateRange 配合
dateRange
  • Array[string] 限制时间范围
  • 该属性必须与 isLimit 配合
  • 时间范围的日期不能与date冲突,
    如:date是2024-5-31, 时间范围的日期必须是在31日内的时间
  • 时间范围格式:“YYYY-MM-DD HH:mm:ss”
  • 例如:[‘2024-5-31 00:00:00’, ‘2024-5-31 23:59:59’]
date
  • String 用于确定时间轴展示哪一天
  • 该属性不能携带时间:“YYYY-MM-DD”
  • 时间范围的日期不能与date冲突,
    如:date是2024-5-31, 时间范围的日期必须是在31日内的时
zoomLevel
  • Number 用户控制时间轴的缩放等级
  • 该属性将会使用时间范围中,选中项的beginTime时间为时间轴中心点
  • 但会受时间范围限制的影响【既有时间范围限制,时间轴开始时间最小为beginTime。时间轴结束时间,最大为finishTime】
    在这里插入图片描述

方法

update
  • params[segments]
  • 该方法在点击时间范围时执行,抛出被点击的时间范围数据
wheel
  • params[zoom,event]
  • 该方法在滚轮缩放事件时执行,抛出当前的缩放等级
move
  • params[text, event]
  • 该方法在鼠标移动时执行,抛出当前的时间指示器事件点
down
  • params[event]
up
  • params[event]
out
  • params[event]

初始化参数
{
  ...
  // 记录鼠标按下
  isMouseDown: false,
  canvas: null,
  canvasLine: null,
  contain: null,
  ctx: null,
  ctxLine: null,
  width: 0,
  height: 0,
  // 鼠标按下x值
  mouseDownPx: 0,
  // 鼠标移动x值
  mouseMovePx: 0,
  // 缓存鼠标按下时的开始时间
  mousedownCachStartTime: 0,
  // 全部时间轴展示的时间小时数,既缩放等级
  zoom: [0.1, 0.5, 1, 2, 6, 12, 24],
  // 所在缩放等级下,每个格子展示的时间范围,如24对应1/2,既半小时
  zoom_hour_grid: [1/60, 1/60, 1/60, 1/30, 1/8, 1/4, 1/2],
  // 缩放等级指针
  currentIndex: 6,
  // 开始时间
  startTime: 0,
  // 鼠标移动时的时间
  currentTime: '',
  // 一小时对应的时间,单位毫秒
  one_hour_stamp: 60 * 60 * 1000,
  // 缩放显示规则
  zoom_date_show_rule: [
    () => {// 全都显示
      return true
    },
    () => {// 全都显示
      return true
    },
    date => {// 每五分钟显示
      return date.getMinutes() % 5 === 0
    },
    date => {// 显示10、20、30...分钟数
      return date.getMinutes() % 10 === 0
    },
    date => {// 显示整点和半点小时
      return date.getMinutes() === 0 || date.getMinutes() === 30
    },
    date => {// 显示整点小时
      return date.getMinutes() === 0
    },
    date => {
      // 显示2、4、6...整点小时
      // return date.getHours() % 2 === 0 && date.getMinutes() === 0
      // 显示整点小时
      return date.getMinutes() === 0
    }
  ],
  // 时间范围片段数据
  timeSegments: [],
  ...
}

开始绘制

draw() {
  // 绘制时间轴背景
  this.drawGridBg()
  // 绘制时间轴
  this.drawTimeGrid()
  // 绘制时间段
  this.drawTimeSegments()
},
  1. 绘制时间轴背景
drawGridBg() {
  this.ctx.fillStyle = this.gridStyle.bgColor;
  this.ctx.fillRect(0, 0, this.width, this.gridStyle.bgHeight)
  this.ctx.fill();
},
  1. 绘制时间轴
drawTimeGrid() {
  // 一格多少毫秒,将每格代表的小时数转成毫秒数就可以了
  const wMs = this.zoom_hour_grid[this.currentIndex] * this.one_hour_stamp
  // 要画多少个格子
  const gridNum = this.zoom[this.currentIndex] / this.zoom_hour_grid[this.currentIndex];
  // 每个格子所占的宽度单位px
  const wPx = this.width / gridNum;

  // 时间偏移量,不懂的可以去看参考文档,里面有解释
  const msOffset = wMs - (this.startTime % wMs)
  // 距离偏移量,不懂的可以去看参考文档,里面有解释
  const pxOffset = (msOffset / wMs) * wPx;
  
  // 先画出格子
  for (let i = 0; i < gridNum; i++) {
    const x = i * wPx + pxOffset;
    const time = this.startTime + (wMs * i) + msOffset;
    const show = this.zoom_date_show_rule[this.currentIndex];
    let text = moment(time).format('HH:mm');
    let h = this.gridStyle.lineHeight;
    let lineWidth = this.gridStyle.lineWidth;
    let fillStyle = this.gridStyle.color;
    if (text === '00:00') {
      text = moment(time).format('MM-DD');
      fillStyle = this.gridStyle.dateColor;
    }
    if (show(new Date(time))) {
      // 画出时间
      this.ctx.fillStyle = fillStyle
      this.ctx.fillText(text, x - 14, h + 12)
    } else {
      h = lineWidth / 2;
    }
    // 画出刻度线
    this.drawLine(this.ctx, x, 0, x, h, lineWidth, this.gridStyle.lineColor)
  }
},
  1. 绘制时间范围片段
drawTimeSegments(e) {
  // e为点击事件的event对象
  if (e && this.isMouseDown) return;
  // 计算每一像素所占的时间,单位毫秒
  const pxPerMs = this.width / (this.zoom[this.currentIndex] * this.one_hour_stamp)
  this.timeSegments.forEach(t => {
    if (e) t.active = false;
    // 只在canvas范围内绘制
    const isDraw = t.beginTime <= (this.zoom[this.currentIndex] * this.one_hour_stamp + this.startTime) && t.finishTime >= this.startTime
    if (isDraw) {
      let x = (t.beginTime - this.startTime) * pxPerMs;
      let w = (t.finishTime - t.beginTime) * pxPerMs
      if (x < 0) {
        x = 0;
        // 取canvas可见区域的部分
        w = (t.finishTime - this.startTime) * pxPerMs
      }
      this.ctx.beginPath()
      this.ctx.rect(x, t.y, w, t.h)
      // 处理点击事件
      if (e && this.ctx.isPointInPath(e.offsetX, e.offsetY)) {
        t.active = true;
        this.$emit('update', t)
      }
      t.style = {
        bgColor: t.active ? t.bgColorActive : t.bgColor,
        borderColor: t.active ? t.borderColorActive: t.borderColor
      }
      this.ctx.fillStyle = t.style.bgColor
      this.ctx.strokeStyle = t.style.borderColor
      this.ctx.fill()
      this.ctx.stroke()
    }
  })
},

效果:
在这里插入图片描述

处理鼠标事件
onMousemove 鼠标移动事件
onMousemove(e) {
  const {left} = this.canvasLine.getBoundingClientRect()
  const x = e.clientX - left
  // 计算时间轴每一像素所占的毫秒数
  const pxPerMs = this.zoom[this.currentIndex] * this.one_hour_stamp / this.width
  // 记录鼠标移动过程中,x方向的距离
  this.mouseMovePx = x;
  // 鼠标拖动
  if (this.isMouseDown) {
    // 计算拖动的距离
    const diffX = x - this.mouseDownPx
    // 计算开始时间的偏移量
    this.startTime = this.mousedownCachStartTime - Math.round(diffX * pxPerMs)
    if (this.isLimit) {
      this.limitTimeRange()
    }
    // 重新绘制时间轴
    this.clearCanvas(this.ctx, this.width, this.height)
    this.draw()
  }

  // 鼠标移动 获取鼠标所在的时间
  this.currentTime = moment(this.startTime + x * pxPerMs).format('YYYY-MM-DD HH:mm:ss')
  // 鼠标移动 绘制时间指示器
  this.drawCurrentDate(x);
},
//时间指示器
drawCurrentDate(x) {
   const h = this.hoverStyle.lineHeight;
   const lineWidth = this.hoverStyle.lineWidth;
   const text = moment(this.currentTime).format('YYYY-MM-DD HH:mm:ss')
   let tX = x - 20;
   // 处理时间指示器,时间文字 超出左侧边界 问题
   if (tX < 0) {
     tX = 0;
   }
   // 处理时间指示器,时间文字 超出右侧边界 问题
   if (tX + 100 > this.width) {
     tX = this.width - 100;
   }
   this.clearCanvas(this.ctxLine, this.width, this.height)
   this.drawLine(this.ctxLine, x, 0, x, h, lineWidth, this.hoverStyle.lineColor)
   this.ctxLine.fillStyle = this.hoverStyle.color
   this.ctxLine.fillText(text, tX, h + 12)
 },
onMousewheel 滚轮缩放事件

通过鼠标位置的x值计算缩放,鼠标点缩放效果会更流畅
通过鼠标位置的 时间 值计算缩放,会引起想放大某一个时间范围时,鼠标弹跳不准确的问题

onMousewheel(e) {
  e = e || window.event;
  let delta = Math.max(-1, Math.min(1, e.wheelDelta || -e.detail))
  if (delta < 0) {
    // 缩小
    if (this.currentIndex + 1 >= this.zoom.length - 1) {
      this.currentIndex = this.zoom.length - 1
    } else {
      this.currentIndex++
    }
  } else if (delta > 0) {
    // 放大
    if (this.currentIndex - 1 <= 0) {
      this.currentIndex = 0
    } else {
      this.currentIndex--
    }
  }
  this.clearCanvas(this.ctx, this.width, this.height);

  // mouseMovePx 重新计算起始时间点,根据当前距左侧的距离计算出
  // 计算出当前时间轴每一像素所占时间,单位毫秒
  const pxPerMs = (this.zoom[this.currentIndex] * this.one_hour_stamp) / this.width;
  this.startTime = new Date(this.currentTime).getTime() - pxPerMs * this.mouseMovePx
  
  // 重新计算起始时间点,当前时间-新的时间范围的一半
  // this.startTime = new Date(this.currentTime).getTime() - this.getTotalMs() / 2;
  if (this.currentIndex === this.zoom.length - 1) {
    this.startTime = new Date(moment(this.date).format('YYYY-MM-DD 00:00:00')).getTime();
  }
  if (this.isLimit) {
  	// 时间范围限制
    this.limitTimeRange()
  }
  this.draw()
},
limitTimeRange() {
  if (this.dateRange.length === 0) {
    return;
  }
  // --------------------------------- 限制时间轴范围 ---------------------------------------------
  // 如果拖动超出当前选择日期的时间, 禁止再次拖动
  const start = new Date(this.dateRange[0]).getTime();
  const end = new Date(this.dateRange[1]).getTime();
  // 一共可以绘制的格数,时间轴的时间范围小时数除以每格代表的小时数,24/0.5=48
  let gridNum = this.zoom[this.currentIndex] / this.zoom_hour_grid[this.currentIndex];

  // 一格多少毫秒,将每格代表的小时数转成毫秒数就可以了
  let msPerGrid = this.zoom_hour_grid[this.currentIndex] * this.one_hour_stamp;

  // 时间偏移量,初始时间除每格时间取余数,
  let msOffset = msPerGrid - (this.startTime % msPerGrid)
  // 距离偏移量,时间偏移量和每格时间比例乘每格像素
  let graduationTime = this.startTime + msOffset + (gridNum - 1) * msPerGrid;
  let endX = new Date(graduationTime).getTime();
  
  if (this.startTime <= start) {
    this.startTime = new Date(start).getTime()
  }
  if (endX >= end) {
    this.startTime = new Date(end).getTime() - this.getTotalMs();
  }
  // ----------------------------------- 限制时间轴范围 -------------------------------------------
},

--------------- 下班了,后续有时间补上,见谅 ----------------

onMousedown, onMouseup 鼠标按下,抬起事件

这里主要处理一些参数,另外模拟了鼠标点击事件

onMousedown(e) {
   const {left} = this.canvasLine.getBoundingClientRect();
   // 记录鼠标已经按下
   this.isMouseDown = true;
   // 记录按下时的x值
   this.mouseDownPx = e.clientX - left;
   // 记录一下鼠标按下时的开始时间
   this.mousedownCachStartTime = this.startTime;
 },
 onMouseup(e) {
   const {left} = this.canvasLine.getBoundingClientRect()
   const x = e.clientX - left
   const diffX = x - this.mouseDownPx;
   this.isMouseDown = false;
   this.mouseDownPx = 0;
   // 如果没有移动,则视为点击事件,之所以不用click是因为,click与mousedown冲突,造成点击后出现视觉bug
   if (diffX === 0) {
     this.drawTimeSegments(e)
   }
 },
剩下的就没多少东西了,一个鼠标移出事件,和工具函数。给大家展示一下吧
// 画线段方法
drawLine(ctx, x1, y1, x2, y2, lineWidth = 1, color = '#123456') {
  // 开始一段新路径
  ctx.beginPath()
  // 设置线段颜色
  ctx.strokeStyle = color
  // 设置线段宽度
  ctx.lineWidth = lineWidth
  // 将路径起点移到x1,y1
  ctx.moveTo(x1, y1)
  // 将路径移动到x2,y2
  ctx.lineTo(x2, y2)
  // 把路径画出来
  ctx.stroke()
},
onMouseout(e) {
  this.isMouseDown = false;
  this.mouseDownPx = 0;
  this.clearCanvas(this.ctxLine, this.width, this.height)
},
getTotalMs() {
  return this.zoom[this.currentIndex] * this.one_hour_stamp
},
clearCanvas(ctx, w, h) {
  ctx.clearRect(0, 0, w, h)
},
// 时间片段数据,模拟用,实际应用中可以从挂载点传入
initSegmentsData() {
  this.timeSegments = [];
  for (let i = 0; i < 50; i++) {
    const finishTime = i !== 49 ? this.startTime + (60 * 1000 * (i + 1)) : this.zoom[this.currentIndex] * this.one_hour_stamp
    const segments = {
      id: `${100100 + i}`,
      beginTime: this.startTime + (10 * 1000) + (60 * 1000 * i),
      finishTime,
      active: i === 0,
      lineWidth: '2',
      y: 54,
      h: 42,
      bgColor: this.segmentBgColor,
      borderColor: this.segmentBorderColor,
      bgColorActive: this.segmentBgColorActive,
      borderColorActive: this.segmentBorderColorActive,
    }
    this.timeSegments.push(segments);
  }
},

能看到这里的都是真爱,麻烦点个赞,这是我努力的动力,感谢!!!

  • 8
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值