简介:canvas时间轴交互插件,适用于视频回放、监控录像检索等需要按时间定位的场景。基于原生canvas绘图并结合jQuery封装,支持拖动时间轴、点击定位时间点、添加录像块标记、缩放时间刻度,可帮助前端开发者快速在项目中集成视频时间轴模块。资源包共6个文件,压缩后仅90KB,包含3个JS脚本、2个HTML示例页面和1个README说明文档。JS脚本涵盖核心时间轴逻辑与jQuery依赖,HTML页面提供可直接运行的效果展示,README则补充了使用方法与配置说明。轻量小巧,适合需要自研时间轴功能的中级前端工程师参考改造。目前已有4077人学习下载。若你在做视频回放、安防监控或多媒体管理后台的时间轴部分,这份插件能提供完整的交互实现思路与可用代码框架,节省自行从零绘图与事件处理的成本。
1. 用 Canvas 写时间轴插件,到底解决什么问题
做视频回放、监控录像检索或者行车轨迹复盘时,时间轴是逃不掉的核心控件。你要在一条横轴上把全天录像切成若干段,能拖、能缩放、能点某个时刻立刻跳转。这类组件我用 DOM 写过两版,节点一多就发虚卡顿,后来切成 Canvas 重写,才把拖动、点击返回时间点、增加录像块、放大缩小这些交互全部收敛到一套坐标换算里。下面把 Canvas 时间轴插件的完整思路、核心算法和踩过的坑整理出来,给正在做同类功能的同学一条能直接走通的路。
2. Canvas 时间轴的地基:数据模型与渲染管线
2.1 为什么用 Canvas 而不是 DOM 或 SVG
用 DOM 做时间轴最直观:每个录像块一个 div,绝对定位排开,样式用 CSS 控制。但它的瓶颈出现在节点数量上。监控录像场景里,一个通道一天可能产生几百段移动侦测录像,八通道就是几千段。把这些块全部渲染成 DOM 节点后,拖动时每一帧都要触发布局计算,浏览器会在 reflow 和重绘之间来回拉扯,掉帧是必然的。
SVG 比 DOM 好一些,它有结构化的图形节点和原生的点击事件,适合做元素级交互。但同样的问题在节点数量过万时会出现,而且 Canvas 的绘制指令远比 DOM/SVG 的节点树轻量。用 Canvas 写时间轴,本质上是你自己掌控每一帧的像素输出:缩放是改一个系数,拖动是改一个偏移量,然后重绘一帧,没有布局、没有样式计算、没有事件冒泡的开销。
我的判断标准很简单:如果一屏内块数量不超过 50、只有点击没有拖动和缩放,DOM 省事;一旦叠加拖动、缩放、框选这些连续交互,Canvas 的收益立刻体现出来。后面要讲的录像块、刻度、播放指针,全部是 Canvas 的绘图指令,不需要任何 DOM 节点参与。
2.2 时间与坐标的映射:msPerPixel 是唯一主线
时间轴插件的一切交互,本质上都是时间和像素之间的换算。你需要两个状态变量:视图左边缘对应的时间viewStart,以及缩放系数msPerPixel(每个像素代表多少毫秒)。这两个变量定了,坐标映射函数就定了。
// ========== 时间轴核心状态 ========== const timeline = { viewStart: 0, // 视图左边缘对应的时间(毫秒时间戳) msPerPixel: 1000, // 每个像素代表多少毫秒,值越小越精细 width: 800, // 画布宽度(CSS 像素) }; // 时间 → 像素横坐标 function timeToX(t) { return (t - timeline.viewStart) / timeline.msPerPixel; } // 像素横坐标 → 时间 function xToTime(x) { return timeline.viewStart + x * timeline.msPerPixel; }这两个函数就是整个插件的坐标系。拖动时改viewStart,缩放时改msPerPixel,然后重绘。所有交互最终都归结为"先换算到时间,再改状态,再重绘"这个循环。我强烈建议把这两个函数放到模块最顶部,并且只在两个地方修改timeline对象,否则调试时会出现坐标对不上的玄学问题。
| 参数 | 含义 | 典型取值 |
|---|---|---|
viewStart | 视图可见区左端时间 | 动态变化,受拖动和缩放影响 |
msPerPixel | 每像素对应毫秒数 | 录像检索 500~2000;帧级编辑 20~100 |
提示:也可以用
pixelsPerSecond(每秒像素数)作为缩放系数,两种都行。我习惯用msPerPixel,因为它和时间戳同单位,xToTime里少一步除法,心智负担更小。
2.3 数据模型:只存时间,不存坐标
数据模型的设计决定了这个插件能走多远。录像块、播放指针这些信息,应该全部以"时间"为单位存储,坐标是派生值,渲染时才计算。如果把 x、width 存在块数据里,一旦缩放,所有块的坐标全部失效,你还得写一段迁移逻辑去更新它们——我第一次做的时候就踩了这个坑,后来统一改成只存时间。
// 时间轴插件的数据模型 const state = { viewStart: Date.parse('2024-01-01T00:00:00+08:00'), msPerPixel: 1000, duration: 7 * 24 * 3600 * 1000, // 总时长 7 天,单位 ms // 录像块列表:这是唯一数据源 blocks: [ { id: 'blk_001', start: Date.parse('2024-01-01T07:00:00+08:00'), end: Date.parse('2024-01-01T08:30:00+08:00'), color: '#3b82f6', label: '门口摄像头-晨间', }, ], // 播放指针:当前停留的时间点 playhead: Date.parse('2024-01-01T07:30:00+08:00'), };这里的关键原则是数据和渲染分离。blocks数组是唯一数据源,增删、合并、查询都操作数组,渲染只是把数组内容画出来。添加、删除录像块的功能都在数组上做操作,不用关心画布上是什么状态。这样后续加框选、批量删除、按通道过滤,都只是对数组做过滤和变换。
3. 拖动与点击返回时间点:事件坐标的精确换算
3.1 用 Pointer Events 统一鼠标、触屏和触控笔
时间轴插件大概率要跑在触屏设备上,比如平板上的监控客户端。如果监听 mouse 系列事件,触屏上完全不触发;如果监听 touch 系列,又拿不到鼠标的 hover 语义。Pointer Events 一套监听就能同时覆盖鼠标、触摸和触控笔,省掉两套事件兼容代码。
const canvas = document.getElementById('timelineCanvas'); canvas.addEventListener('pointerdown', handleDown); canvas.addEventListener('pointermove', handleMove); canvas.addEventListener('pointerup', handleUp); canvas.addEventListener('pointercancel', handleUp);注意最后一个pointercancel:手指滑动时被系统手势打断(比如下拉通知栏),浏览器会触发 pointercancel。如果不处理这个事件,就会出现"按下去但永远不抬起"的假死状态。把handleUp同时绑到 pointerup 和 pointercancel 是常见做法,保证任何情况下状态都能复位。
3.2 拖动逻辑:改 viewStart 而不是挪元素
拖动时间轴的直觉是"把内容往左推",但在 Canvas 里没有元素可挪。拖动时应该修改视图起点时间,然后重绘整帧。这里有一个容易搞反的符号问题:鼠标向右拖(dx 为正),视图要回看更早的内容,viewStart要减小;鼠标向左拖(dx 为负),视图要快进到更晚的内容,viewStart要增大。
let isDragging = false; let lastClientX = 0; let totalMoved = 0; // 累计位移,用于区分点击和拖动 let activePointerId = null; function handleDown(e) { isDragging = true; lastClientX = e.clientX; totalMoved = 0; activePointerId = e.pointerId; // 关键:把指针事件捕获到 canvas 上,移出画布也不会断 canvas.setPointerCapture(e.pointerId); } function handleMove(e) { if (!isDragging || e.pointerId !== activePointerId) return; const dx = e.clientX - lastClientX; lastClientX = e.clientX; totalMoved += Math.abs(dx); // 向右拖 → 视图时间变小(回看更早内容) // 向左拖 → 视图时间变大(快进到更晚内容) state.viewStart -= dx * state.msPerPixel; clampViewStart(); // 防止拖出 [0, duration] 范围 render(); }这里有两个细节值得说。第一,用e.clientX而不是e.offsetX:offsetX在 canvas 有 CSS 缩放或边框时会产生偏差,而clientX是窗口坐标系,两帧之间的差值天然准确。第二,setPointerCapture这行是血泪经验——不写它,鼠标快速拖出画布边界时,move 和 up 事件全部丢失,松手后时间轴还在跟着"幽灵鼠标"平移,看起来就像控件失控了。
clampViewStart也很重要:总时长是 7 天,拖到尽头不能继续往外拖出空白区域。实现很简单,把viewStart限制在[0, duration - width * msPerPixel]区间内,最小值是 0,最大值保证视图右边缘不超过总时长。
3.3 点击返回时间点:怎么区分点击和拖动
点击和拖动在 pointerdown 时刻是无法区分的,只能在 pointerup 时通过累计位移判断。位移小于阈值就当作点击,否则是拖动。这个阈值我一般用 5px,鼠标足够灵敏;触屏建议放宽到 8~10px,否则手指的轻微抖动会被误判为拖动。
const CLICK_THRESHOLD = 5; // 位移小于 5px 视为点击 function handleUp(e) { if (!isDragging) return; isDragging = false; canvas.releasePointerCapture(e.pointerId); if (totalMoved >= CLICK_THRESHOLD) return; // 是拖动,不处理 // 是点击:换算坐标,设置播放指针 const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const t = xToTime(x); state.playhead = t; render(); }"点击返回时间点"这个功能到这一步就已经落地了:点击换算成时间,写入playhead,重绘时在对应 x 位置画一条指针线。这里要注意的是getBoundingClientRect()必须在事件处理函数内部实时获取,不要在初始化时缓存它——页面滚动、窗口 resize 都会让它的值失效,缓存了就会出现点击位置偏移几个像素的怪问题。
3.4 时间刻度自适应:刻度间距永远不小于 60px
刻度是时间轴的"坐标系视觉化"。缩放级别一变,固定步长的刻度要么挤成一团,要么稀疏得没法看。所以步长必须根据当前msPerPixel自适应:从候选步长列表里选出第一个满足"刻度间距不小于 60px"的步长。
// 候选步长按 1-2-5 基数递增 const STEP_CANDIDATES = [ 100, 500, 1000, 5000, 10000, 30000, 60000, 300000, 900000, 3600000, 10800000, 43200000, 86400000, ]; function getAdaptiveStep() { const minPx = 60; for (const step of STEP_CANDIDATES) { if (step / state.msPerPixel >= minPx) return step; } return 86400000 * 7; // 极端情况:整个时长一根刻度 } function drawRuler(ctx) { const step = getAdaptiveStep(); const firstT = Math.floor(state.viewStart / step) * step; const lastT = state.viewStart + canvas.clientWidth * state.msPerPixel; ctx.font = '11px system-ui, sans-serif'; ctx.fillStyle = '#666'; for (let t = firstT; t <= lastT; t += step) { const x = timeToX(t); ctx.fillText(formatTime(t), x + 4, 18); } }Math.floor(state.viewStart / step) * step这行的作用是把起始刻度对齐到步长的整数倍,否则拖动时刻度线会"跳动"。候选步长按 1-2-5 基数设计,保证任意缩放级别下刻度的数量稳定在 5~15 根,不会出现从 10 根突变成 100 根的情况。
4. 录像块增删与锚点缩放:交互如何落到状态上
4.1 录像块渲染与视口裁剪
录像块是时间轴上真正承载业务信息的元素。渲染本身不复杂——把每个块的起止时间换算成 x 坐标,画一个矩形。真正影响性能的是视口裁剪:当录像块数量达到几千个时,每帧全量绘制所有块是巨大的浪费,因为视野里可能只有几十个。
const BLOCK_HEIGHT = 24; const BLOCK_TOP = 32; // 刻度下方留出录像块区域 function drawBlocks(ctx) { const leftT = state.viewStart; const rightT = state.viewStart + canvas.clientWidth * state.msPerPixel; for (const b of state.blocks) { // 视口裁剪:完全在视野外的块直接跳过 if (b.end < leftT || b.start > rightT) continue; const x0 = timeToX(b.start); const x1 = timeToX(b.end); const w = x1 - x0; ctx.fillStyle = b.color; ctx.globalAlpha = 0.55; ctx.fillRect(x0, BLOCK_TOP, w, BLOCK_HEIGHT); ctx.globalAlpha = 1.0; ctx.strokeStyle = 'rgba(255,255,255,0.75)'; ctx.lineWidth = 1; ctx.strokeRect(x0 + 0.5, BLOCK_TOP + 0.5, w - 1, BLOCK_HEIGHT - 1); } }裁剪判断用时间比较:b.end < leftT || b.start > rightT说明块完全在视野外,直接跳过。一万个块在视野里只画三十个矩形,性能差距就是在这里拉开的。填充用半透明、描边用白色,这样当两个录像块在时间上有重叠时,能通过颜色深浅看出层次。
渲染节流还有一个细节:如果一帧内多次修改状态(比如拖动时连续触发 move 事件),每次 move 都调用 render 会重复绘制。常见做法是用requestAnimationFrame节流,同一帧内只绘制一次:
let rafId = null; function render() { if (rafId) return; rafId = requestAnimationFrame(() => { rafId = null; drawAll(ctx); }); }4.2 增加录像块:框选是最自然的交互
"增加录像块"有两种常见做法:一种是在空白位置点击,生成一个默认时长的块;另一种是按住拖拽框选一个时间范围。框选更符合直觉,因为录像块本质上就是一个时间段,用户想表达的是"从几点到几点有录像"。
let rubberStart = null; // 框选起点时间 function handleDown(e) { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const t = xToTime(x); // 命中检测:点到了某个录像块内部? const hit = state.blocks.find(b => t >= b.start && t <= b.end); if (hit) { beginDragBlock(hit, e); // 拖动已有块,这部分的逻辑与拖动视图类似 } else { rubberStart = t; // 空白处按下,开始框选新块 } } function handleUp(e) { // ...点击判定逻辑... if (rubberStart !== null) { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const tEnd = xToTime(x); // 最短 1 秒的块才有意义,防止误触 if (Math.abs(tEnd - rubberStart) > 1000) { state.blocks.push({ id: genId(), start: Math.min(rubberStart, tEnd), end: Math.max(rubberStart, tEnd), color: pickColor(), label: formatTime(rubberStart), }); } rubberStart = null; } render(); }框选的实现思路是:空白处按下记录起点时间,松开时记录终点时间,保证start小于end后放入数组。整个交互不需要额外的"添加模式"开关,用户操作本身就是意图表达。框选过程中还可以画一个半透明的选区矩形,让用户看到正在选择的范围,这个矩形存在局部变量里,每次 move 时更新并重绘。
最短时长限制是必要的防误触:只点了一下没拖动,rubberStart和tEnd相同,不会产生长度为 0 的录像块。genId用自增序号或者时间戳拼接随机数都可以,保证块 id 在批量操作时不冲突。
4.3 放大缩小:以鼠标位置为锚点的缩放计算
缩放是时间轴里最容易做错的功能。很多初版实现只改msPerPixel完事,结果鼠标指着的那个时间点,缩放后从鼠标下"溜走"了。正确的做法是锚点缩放:缩放前后,鼠标所在位置对应的时间保持不变。
// 滚轮缩放:以鼠标位置为锚点 canvas.addEventListener('wheel', (e) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; if (x < 0 || x > canvas.clientWidth) return; const anchorTime = xToTime(x); // 1. 记住鼠标下的时间 const factor = e.deltaY < 0 ? 1 / 1.25 : 1.25; state.msPerPixel = clamp( state.msPerPixel * factor, 20, // 最小:每像素 20ms,约单帧粒度 24 * 3600 * 1000 // 最大:每像素一天 ); // 2. 缩放后让 anchorTime 仍然停留在鼠标位置 state.viewStart = anchorTime - x * state.msPerPixel; clampViewStart(); render(); }, { passive: false });核心公式是viewStart' = anchorTime - x * msPerPixel'。先记录锚点时间,改完缩放系数后反推出新的viewStart,锚点自然钉在鼠标下。缩放上下限要根据场景定:监控录像场景到 20ms/px 已经能看清单帧边缘,再小没有实际意义;最大到一天一像素,够用户扫一周的录像了。缩放系数 1.25 是手感比较舒适的档位,想快速缩放可以用 1.5。
提示:wheel 监听必须传
{ passive: false },否则preventDefault()不生效,滚轮缩放时间轴的同时页面会跟着上下滚动。
5. 避坑记录:Canvas 时间轴最常见的 5 个翻车现场
5.1 高分屏文字发虚
现象:在 2x 缩放的高分屏笔记本上,刻度文字边缘发虚,线条粗细不均,看起来像低分辨率渲染。
原因:canvas 的位图尺寸没有跟随devicePixelRatio。默认情况下 canvas 的 bitmap 尺寸等于 CSS 尺寸,高分屏会用多个物理像素去拉伸一个 CSS 像素,文字自然糊。
解决:resize 时把 canvas 的宽高乘以devicePixelRatio,再用setTransform把所有绘制坐标统一到 CSS 像素:
function resizeCanvas() { const dpr = window.devicePixelRatio || 1; canvas.width = canvas.clientWidth * dpr; canvas.height = canvas.clientHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 之后绘制全部用 CSS 像素坐标 }注意setTransform和scale的区别:如果多次调用scale,变换会叠加;setTransform是覆盖设置。每次 resize 重设一次,后续绘制不要再用ctx.scale,否则你会发现坐标越画越偏。监听resize事件时要用requestAnimationFrame节流,否则窗口拖动时 resize 事件高频触发,canvas 会反复清空重绘。
5.2 滚轮缩放"跳变"
现象:鼠标放在 07:00 的位置滚轮缩放,缩放后 07:00 跑到屏幕别的位置了,得重新找鼠标。
原因:缩放只改了msPerPixel,没有同步修改viewStart,视图中心或左边缘的时间变了,锚点自然保不住。
解决:用锚点缩放公式,见 4.3 节。调试时可以加一行console.log(anchorTime, xToTime(x)),缩放前后对比鼠标位置的时间是否一致。如果两者相等,说明锚点逻辑正确。
5.3 拖动时鼠标飞出 Canvas 就断触
现象:快速拖动时间轴,鼠标移出画布边缘后松手,时间轴还在缓速移动或者突然卡住,好像有只"幽灵手"在拖。
原因:pointermove和pointerup只在指针位于元素上方时触发。鼠标拖出 canvas 后,move 事件目标变成了 canvas 外面的元素,canvas 收不到 up 事件,isDragging永远停在 true。
解决:pointerdown 时调用canvas.setPointerCapture(e.pointerId),把指针事件捕获到 canvas 上,拖到天涯海角 move/up 都发给 canvas。pointerup 或 pointercancel 时记得releasePointerCapture。这是做 Canvas 交互组件必学的第一个 API,忘了它,任何拖动功能都有概率翻车。
5.4 录像块多到卡顿
现象:录像块数量到几千个时,拖动时间轴明显掉帧,块渲染跟不上鼠标。
原因:每帧全量绘制所有块,且没有做视口裁剪;再加上拖动时每帧多次触发 render,重复绘制浪费严重。
解决:三层优化按顺序做。第一是视口裁剪,只画视野内的块,见 4.1 节;第二是requestAnimationFrame节流,一帧只重绘一次;第三是把颜色相同且时间相邻的块合并成一个矩形再绘制,减少 draw call。做到前两层,上万个块在普通笔记本上 60 帧没有压力。
5.5 跨天时间显示成 00:00
现象:缩放到小时级别,1 月 1 日 23:00 和 1 月 2 日 01:00 的刻度显示的都是"23:00""01:00",完全看不出跨天;到了 00:00 位置,屏幕上出现多个"00:00"。
原因:formatTime只格式化了时分秒,没有根据缩放级别决定是否显示日期。
解决:根据当前msPerPixel自适应格式。步长大于等于 1 小时时带上日期,大于等于 1 天时显示"01-02 周三"这种格式:
function formatTime(t) { const d = new Date(t); const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`; if (state.msPerPixel > 3600000) { // 小时级别以上,补上日期防混淆 return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`; } return hm; }另外要注意时区问题:时间戳尽量用毫秒时间戳(Date.parse或getTime()得到),不要用本地时间字符串直接算差值,否则夏令时和时区切换会让时间轴出现一小时的整体偏移。
6. 进阶:分层渲染、恒定字号与惯性拖动
6.1 双层 Canvas 分层渲染
刻度这类静态内容只在缩放时变,录像块和播放指针每帧都动。画在同一层,每次 render 都得重画全部,块多时就卡。常见做法是用两层 canvas 叠放:底层画刻度网格,顶层画块、指针和框选。刻度变化时才重绘底层,拖动时只画顶层。
function render({ rulerDirty = false } = {}) { if (rulerDirty) { const rctx = rulerLayer.getContext('2d'); rctx.clearRect(0, 0, width, height); drawRuler(rctx); } const ctx = mainLayer.getContext('2d'); ctx.clearRect(0, 0, width, height); drawBlocks(ctx); drawPlayhead(ctx); }两层 canvas 用 CSS 绝对定位叠放,宽高一致;resize 时两层都要处理 devicePixelRatio,否则刻度和块会错位。
6.2 不要用 ctx.scale 实现缩放
用ctx.scale会把文字和线宽一起放大,缩放到小时级别时刻度字变成大黑块。正确做法是只用msPerPixel换算坐标,drawRuler里字号固定 11px,无论缩放到哪一级,刻度文字始终清晰可读。
6.3 惯性拖动给手感一个台阶
在 pointerup 前用最近几次 move 事件估算瞬时速度,松手后让视图按速度衰减滑行,这是桌面端时间轴产品常见的交互细节。
let inertiaVx = 0; function handleUp(e) { if (totalMoved >= CLICK_THRESHOLD) { inertiaVx = lastVelocityX; // 最近 3 个 move 计算的 px/ms requestAnimationFrame(inertiaStep); } } function inertiaStep() { if (Math.abs(inertiaVx) < 0.1) return; state.viewStart -= inertiaVx * state.msPerPixel; inertiaVx *= 0.92; // 摩擦系数 clampViewStart(); render(); requestAnimationFrame(inertiaStep); }摩擦系数 0.92 是我调出来的手感:0.9 太脆,0.95 太滑,做成可配置项更好。惯性拖动要和 5px 点击阈值配合好,否则松手时容易误触发放跳转。
这套插件最核心的一句话:把所有交互都收敛成对viewStart和msPerPixel两个变量的修改,渲染只是这两个变量的纯函数。我第一次做时把坐标存在数据里,一缩放全乱;改成"数据只存时间、坐标只算不存"之后,框选、批量删除、缩放都顺了。这也是我后来做任何 Canvas 交互组件都先定义坐标系的原因。希望帮到你。
本文还有配套的精品资源,点击获取