参考文档:手摸手带你实现一个时间轴组件
说明:实现思路来源于上面的文档,在此基础上做了一些调整,以符合项目需求
完成代码在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>
- 一个canvas用于绘制时间轴,一个处理鼠标移动绘制时间指示器
- 模版和样式表都非常简单,不做赘述
实现步骤
实现逻辑:在一个缩放等级下,确定用多少根刻度线画出所有对应的时间范围
如:缩放等级下24小时的时间轴,则要在canvas范围内绘制多根刻度线表示24小时
开始开发
初始化
- 创建一个init方法
- 设置浏览器变化的resize事件
this.init();
window.addEventListener('resize', this.init)
- 设置一下画布的宽高及获取画布上下文:
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()
},
- 绘制时间轴背景
drawGridBg() {
this.ctx.fillStyle = this.gridStyle.bgColor;
this.ctx.fillRect(0, 0, this.width, this.gridStyle.bgHeight)
this.ctx.fill();
},
- 绘制时间轴
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)
}
},
- 绘制时间范围片段
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);
}
},
能看到这里的都是真爱,麻烦点个赞,这是我努力的动力,感谢!!!