vue组件 canvas实现可滑动时间刻度轴
--canvas组件页面
<template>
<div className="time_line_container" ref="containerRef">
<canvas ref="canvasRef" height="40" width="100" />
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref, watchEffect } from "vue";
import { formatTimeLine } from "@/utils";
import "./index.scss";
import { throttle } from "lodash";
import moment, { now } from "moment";
const props = defineProps({
timeParts: {
type: Array,
default: [],
},
isMove: {
type: Boolean,
default: false,
},
currentTime: {
type: Number,
default: new Date().getTime(),
},
});
const emits = defineEmits(["changeCallback"]);
const getPixelRatio = function (context) {
const backingStore =
context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio ||
1;
return (window.devicePixelRatio || 1) / backingStore;
};
const ratio = getPixelRatio(window);
const canvasRef = ref(null);
const containerRef = ref(null);
const ctxRef = ref(null);
// 可选的每个间隔代表多少分钟
const minutePerStep = ref([
1, 2, 5, 10, 15, 20, 30, 60, 120, 180, 240, 360, 720, 1440,
]);
// 最小刻度间距
const minScaleSpacing = ref(20);
// 整个时间轴表示的时间长度
const totalRulerHours = ref(20);
// 允许的最小大格长度px值 如果调小 大格会变密集
const minLargeScaleSpacing = ref(100);
// 缩放层级
const zoom = ref(24);
const currentTime = ref(props.currentTime);
const timeParts = ref(props.timeParts);
const isMove = ref(false);
// 游标颜色
const drawCursorColor = "#3B5EFF";
// 刻度处背景颜色
const fillScaleBgColor = "#DCDFE6";
// 刻度颜色
const drawScaleColor = "#606266";
// 鼠标悬浮移动 游标颜色
const hoverMoveColor = "#5674fd";
// 录像时间块颜色
const fillTimePartsColor = "rgba(140, 158, 238 , .5)";
const moveTimer = ref(null);
const startTimestamp = ref(0);
// 鼠标是否被按下 用来确认时hover事件还是拖拽事件
const isMouseDownFlag = ref(false);
// 是否拖拽 用来确认mouseup时是点击事件还是拖拽事件
const isDragFlag = ref(false);
// 鼠标按下时鼠标x位置 在处理拖拽事件中用来比对
const mousedownX = ref(0);
const refreshStartTimestamp = () => {
// 当currentTime改变或者整条时间轴代表的totalHours改变的时候 就刷新左边开始时间
startTimestamp.value =
currentTime.value - (totalRulerHours.value * 60 * 60 * 1000) / 2;
};
const clearcanvas = () => {
if (!ctxRef.value || !canvasRef.value) return;
ctxRef.value.clearRect(
0,
0,
canvasRef.value.width || 0,
canvasRef.value.height || 0
);
};
const fillScaleBg = () => {
if (ctxRef.value) {
ctxRef.value.fillStyle = fillScaleBgColor;
ctxRef.value.fillRect(0, 0, canvasRef.value?.width || 0, 12);
}
};
const createScaleText = (time) => {
if (
time.getHours() === 0 &&
time.getMinutes() === 0 &&
time.getMilliseconds() === 0
) {
return moment(time).format("YYYY-MM-DD");
}
return moment(time).format("HH:mm");
};
const drawScale = () => {
if (!canvasRef.value || !ctxRef.value) return;
// 一分钟多少像素
let oneMinutePx = canvasRef.value.width / (totalRulerHours.value * 60);
// 一毫秒多少像素
let oneMSPx = oneMinutePx / (60 * 1000);
// 刻度间隔 默认20px
let scaleSpacing = minScaleSpacing.value;
// 每格代表多少分钟
let scaleUnit = scaleSpacing / oneMinutePx;
let len = minutePerStep.value.length;
for (let i = 0; i < len; i += 1) {
if (scaleUnit < minutePerStep.value[i]) {
// 选择正确的刻度单位分钟
scaleUnit = minutePerStep.value[i];
// 每刻度之间的距离 = 一分钟多少像素 * 刻度单位
// 即 scaleUnit = scaleSpacing / oneMinutePx 的变形
// 主要是 totalRulerHours 会变化 需要根据这个的变化来计算...
scaleSpacing = oneMinutePx * scaleUnit;
break;
}
}
// 有刻度文字的大格相当于多少分钟 相当于直尺上的1cm
let mediumStep = 30;
for (let i = 0; i < len; i++) {
if (minLargeScaleSpacing.value / oneMinutePx <= minutePerStep.value[i]) {
mediumStep = minutePerStep.value[i];
break;
}
}
let totalScales = canvasRef.value.width / scaleSpacing;
// 某个刻度距离最左端得距离
let graduationLeft;
// 某个刻度得时间
let graduationTime;
let lineHeight;
// 开始时间 = 中间时间 - 一半得整条时间
startTimestamp.value =
currentTime.value - (totalRulerHours.value * 60 * 60 * 1000) / 2;
// 因为中间点是currentTime.value是固定的 最右边不一定在某个刻度上 会有一定的偏移量
let leftOffsetMs =
scaleUnit * 60 * 1000 - (startTimestamp.value % (scaleUnit * 60 * 1000));
// 开始时间偏移距离(px)
let leftOffsetPx = leftOffsetMs * oneMSPx;
// 一刻度多少毫秒
let oneScalesMS = scaleSpacing / oneMSPx;
// 文字颜色
ctxRef.value.fillStyle = drawScaleColor;
ctxRef.value.font = "17px serif";
// 刻度线颜色
ctxRef.value.strokeStyle = drawScaleColor;
ctxRef.value.beginPath();
// 画刻度线
function drawScaleLine(left, height) {
ctxRef.value.moveTo(left, 0);
ctxRef.value.lineTo(left, height);
ctxRef.value.lineWidth = 1;
}
for (let i = 0; i < totalScales; i++) {
// 距离 = 开始得偏移距离 + 格数 * 每格得px;
graduationLeft = leftOffsetPx + i * scaleSpacing;
// 时间 = 左侧开始时间 + 偏移时间 + 格数 * 一格多少毫秒
graduationTime = startTimestamp.value + leftOffsetMs + i * oneScalesMS;
let date = new Date(graduationTime);
if ((graduationTime / (60 * 1000)) % mediumStep == 0) {
// 大格刻度
lineHeight = 12;
let scaleText = createScaleText(date);
ctxRef.value.fillText(scaleText, graduationLeft - 20, 42);
} else {
// 小格刻度
lineHeight = 8;
}
drawScaleLine(graduationLeft, lineHeight);
}
ctxRef.value.stroke();
};
const fillTimeParts = (part) => {
let onePxsMS =
canvasRef.value.width / (totalRulerHours.value * 60 * 60 * 1000);
let beginX = (part.start - startTimestamp.value) * onePxsMS;
let partWidth = (part.end - part.start) * onePxsMS;
if (part.style && part.style.background) {
ctxRef.value.fillStyle = part.style.background;
} else {
ctxRef.value.fillStyle = fillTimePartsColor;
}
ctxRef.value.fillRect(beginX, 0, partWidth, 20);
};
const drawCursor = () => {
if (!canvasRef.value || !ctxRef.value) return;
ctxRef.value.beginPath();
ctxRef.value.moveTo(canvasRef.value.width / 2, 0);
ctxRef.value.lineTo(canvasRef.value.width / 2, 110);
ctxRef.value.strokeStyle = drawCursorColor;
ctxRef.value.lineWidth = 2;
ctxRef.value.stroke();
ctxRef.value.fillStyle = drawCursorColor;
ctxRef.value.font = "18px serif";
ctxRef.value.fillText(
formatTimeLine(currentTime.value),
canvasRef.value.width / 2 - 100,
canvasRef.value.height - 17
);
};
const init = () => {
refreshStartTimestamp();
// 清空画布
clearcanvas();
// 画刻度处背景
fillScaleBg();
// 画刻度
drawScale();
if (timeParts.value.length) {
timeParts.value.forEach((element) => {
fillTimeParts(element);
});
}
// 画游标
drawCursor();
};
const addTimeParts = (timeParts) => {
setTimeParts(timeParts.value.concat(timeParts));
};
const setIsMove = (Move) => {
if (isMove.value === Move) return;
isMove.value = Move;
const clearTimer = () => {
if (moveTimer.value) {
clearInterval(moveTimer.value);
moveTimer.value = null;
}
};
if (isMove.value) {
// 先清除之前得timer 否则会有两个timer通知进行...
if (moveTimer.value) {
clearTimer();
}
moveTimer.value = setInterval(() => {
currentTime.value += 1000;
init();
}, 1000);
} else {
clearTimer();
}
};
const dragMove = (event) => {
let posX = getMouseXRelativePos(event);
let diffX = posX - mousedownX.value;
let onePxsMS =
canvasRef.value.width / (totalRulerHours.value * 60 * 60 * 1000);
currentTime.value = currentTime.value - Math.round(diffX / onePxsMS);
init();
// 👇因为重新设置了currentTime 所以要重新设置鼠标按下位置
// 否则偏移时间会进行累加 越拖越快越拖越快...
mousedownX.value = posX;
};
const hoverMove = (event) => {
const posX = getMouseXRelativePos(event);
const t = getMousePosTime(event);
init();
ctxRef.value.beginPath();
ctxRef.value.moveTo(posX * ratio + 1, 0);
ctxRef.value.lineTo(posX * ratio + 1, canvasRef.value.height);
ctxRef.value.strokeStyle = hoverMoveColor;
ctxRef.value.lineWidth = 1;
ctxRef.value.stroke();
ctxRef.value.fillStyle = hoverMoveColor;
ctxRef.value.fillText(
formatTimeLine(t),
posX * ratio - 125,
canvasRef.value.height - 5
);
};
const eventListener = {
wheel(event) {
wheelEvent(event);
hoverMove(event);
},
mousedown(event) {
isMouseDownFlag.value = true;
mousedownX.value = getMouseXRelativePos(event);
},
mousemove(event) {
if (isMouseDownFlag.value) {
isDragFlag.value = true;
dragMove(event);
} else {
hoverMove(event);
}
},
mouseup(event) {
if (!isDragFlag.value) {
clickEvent(event);
hoverMove(event);
}
emits("changeCallback", new Date(currentTime.value));
// 初始化这俩值以免影响下次事件判断
isMouseDownFlag.value = false;
isDragFlag.value = false;
},
mouseleave(event) {
init();
// 初始化这俩值以免影响下次事件判断
isMouseDownFlag.value = false;
isDragFlag.value = false;
},
};
const getMousePosTime = (event) => {
let posX = getMouseXRelativePos(event);
// 每像素多少毫秒
let onePxsMS =
canvasRef.value.width / (totalRulerHours.value * 60 * 60 * 1000);
let time = new Date(startTimestamp.value + posX / onePxsMS);
return time;
};
const clickEvent = (event) => {
let time = getMousePosTime(event).getTime();
};
const wheelEvent = (event) => {
event.preventDefault();
// 是放大一倍还是缩小一倍
let delta = Math.max(-1, Math.min(1, event.wheelDelta));
if (delta < 0) {
zoom.value = zoom.value + 4;
if (zoom.value >= 24) {
//放大最大24小时
zoom.value = 24;
}
totalRulerHours.value = zoom.value;
} else if (delta > 0) {
// 放大
zoom.value = zoom.value - 4;
if (zoom.value <= 1) {
//缩小最小1小时
zoom.value = 1;
}
totalRulerHours.value = zoom.value;
}
init();
};
const getMouseXRelativePos = (event) => {
let scrollX = document.documentElement.scrollLeft || document.body.scrollLeft;
let x = event.pageX || event.clientX + scrollX;
// canvasRef.value元素距离窗口左侧距离
let baseLeft = canvasRef.value.getBoundingClientRect().x;
return x - baseLeft;
};
const setCanvasWH = throttle(() => {
if (canvasRef.value && containerRef.value) {
canvasRef.value.width = containerRef.value.clientWidth * ratio;
canvasRef.value.height = containerRef.value.clientHeight * ratio;
}
init();
}, 1000);
watchEffect(() => {
if (props.currentTime) {
//currentTime.value = props.currentTime;
}
if (props.timeParts) {
timeParts.value = props.timeParts;
}
setIsMove(props.isMove);
});
onMounted(() => {
ctxRef.value = canvasRef.value.getContext("2d");
init();
setCanvasWH();
window.addEventListener("resize", setCanvasWH);
canvasRef.value.addEventListener("wheel", eventListener.wheel);
canvasRef.value.addEventListener("mousedown", eventListener.mousedown);
canvasRef.value.addEventListener("mousemove", eventListener.mousemove);
canvasRef.value.addEventListener("mouseup", eventListener.mouseup);
canvasRef.value.addEventListener("mouseleave", eventListener.mouseleave);
});
onUnmounted(() => {
destroy();
});
const destroy = () => {
window.removeEventListener("resize", setCanvasWH);
if (canvasRef.value) {
canvasRef.value.removeEventListener("wheel", eventListener.wheel);
canvasRef.value.removeEventListener("mousedown", eventListener.mousedown);
canvasRef.value.removeEventListener("mousemove", eventListener.mousemove);
canvasRef.value.removeEventListener("mouseup", eventListener.mouseup);
canvasRef.value.removeEventListener("mouseleave", eventListener.mouseleave);
clearcanvas();
}
if (moveTimer.value) {
clearInterval(moveTimer.value);
moveTimer.value = null;
}
};
defineExpose({
currentTime
})
</script>
//--css样式页面:
.time_line_container {
height: 100%;
overflow: hidden;
// height: 50px;
canvas {
width: 100%;
height: 100%;
background: #ebeef5;
}
}
//---
//--调用组件页面 通过循环加载显示 clickVehicleItem.videoCount数量循环显示
<div
class="channel_timeline_wrap1"
v-for="(item, index) in [...Array(+clickVehicleItem.videoCount || 0)]"
:key="index"
>
<!-- @click="changeVIdeoTimeLine(timeFileData[form.channelNo[index]])" -->
<!-- :timeParts="timeFileData[form.channelNo[index]]&&timeFileData[form.channelNo[index]].fileData||[] " -->
<div class="label">通道{{ index + 1 }}</div>
<VideoTimeLine
:timeParts="[{
start: new Date().getTime() - 3 * 3600 * 1000,
end: new Date().getTime() - 1 * 3600 * 1000,
},
{
start: new Date().getTime() - 6 * 3600 * 1000,
end: new Date().getTime() - 4 * 3600 * 1000,
}]"
:isMove="true"
@changeCallback="(val) => changeCallback(val, index)"
:ref="(el) => (videoTimeRefObj[index] = el)"
/>
</div>
const timelinechangeTimes = ref([]);
///--changeCallback 拖动事件_获取时间
const changeCallback = (time, index) => {
console.log(11111, formatTimeLine(time));
timelinechangeTimes.value[index] = formatTimeLine(time);
};
//存储刻度尺上实时时间,通过下标获取
const videoTimeRefObj = ref([])
//参考示例 index下标
const changeChannel = (index)=>{
console.log(formatTimeLine(videoTimeRefObj.value[index].currentTime) );
};
const timeFormat = "YYYY-MM-DD HH:mm:ss";
// 转时间格式
const formatTimeLine = (time, format) => {
return moment(time || new Date()).format(format || timeFormat);
};
css样式:
.channel_timeline_wrap1 {
position: relative;
margin-top: 10px;
height: 45px;
background: #ebeef5;
border-radius: 0px 0px 0px 0px;
opacity: 1;
.label {
position: absolute;
left: 2px;
top: 18px;
font-size: 12px;
}
}
//---
////-- timeFileData字段说明_查询录像时间
timeFileData: {
// 1: {
// deviceId: "100100100018", //终端id
// fileCount: 50, //fileData中文件数量
// fileData: [
// {
// channelNo: "1", //通道号,从1 开始
// beginTime: "2020-11-16 00:00:00", //开始时间
// endTime: "2020-11-17 00:00:00", //结束时间
// fileType: 0, //查询文件类型,0:音视频,1;音频,2:视频,3:音频或视频
// videoType: 1, // 1;主码流,2:子码流
// storageType: 1, // 1:主存储器,2:备份存储器
// fileSize: 1024000, //文件大小 单位字节(Byte)
// start:1672989813770, //刻度尺对应的开始时间显示背景颜色
// end:1672997100403, //刻度尺对应的结束时间显示背景颜色
// },
// {
// channelNo: "2", //通道号,从2 开始
// beginTime: "2020-11-16 00:00:00", //开始时间
// endTime: "2020-11-17 00:00:00", //结束时间
// fileType: 0, //查询文件类型,0:音视频,1;音频,2:视频,3:音频或视频
// videoType: 1, // 1;主码流,2:子码流
// storageType: 1, // 1:主存储器,2:备份存储器
// fileSize: 1024000, //文件大小 单位字节(Byte)
// start:1672979121562, //刻度尺对应的开始时间显示背景颜色
// end:1672986333548, //刻度尺对应的结束时间显示背景颜色
// },
// ],
// },
},
////----
----效果图

【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通