原生JS实现Canvas以鼠标为中心的网格伪缩放效果
以鼠标位置为锚点的Canvas网格缩放实现
核心原理
缩放效果的本质是 保证鼠标当前指向的网格坐标点,在缩放前后的屏幕像素位置完全不变,和Desmos、地图缩放的交互逻辑一致。原有代码仅调整网格单元格大小,没有同步修正视图偏移,因此锚点固定在初始原点。
实现时不需要对每条线的绘制逻辑做硬编码偏移,只需要维护两个全局视图偏移变量,缩放时根据鼠标位置计算偏移补偿值即可,该逻辑可以直接兼容已经实现的画布平移功能。
具体修改步骤
- 新增视图偏移变量,替换原有写死的居中对齐逻辑
新增viewOffsetX、viewOffsetY两个变量存储视图平移量,初始值设为画布宽高的一半,和原来初始居中的效果一致;废弃原有固定写死的context.translate(canvasWidth/2,canvasHeight/2),改为每次绘制时根据实时偏移量做变换。 - 修正鼠标坐标换算逻辑
用标准wheel事件替换已废弃的mousewheel事件,兼容现代浏览器;鼠标坐标统一通过getBoundingClientRect换算为canvas元素内部的本地坐标,消除margin、devicePixelRatio带来的计算误差,去掉原有写死的bodyToCanvas固定偏移值。 - 缩放时计算偏移补偿
缩放前后按如下公式计算偏移,保证鼠标指向的网格点位置不变:缩放比例 = 新单元格大小 / 旧单元格大小 鼠标位置对应的世界网格坐标 = (鼠标屏幕坐标 - 当前视图偏移) / 旧单元格大小 新视图偏移 = 鼠标屏幕坐标 - 世界网格坐标 * 新单元格大小 - 调整网格绘制逻辑
所有网格线、坐标轴的坐标计算统一使用实时单元格大小和视图偏移,去掉硬编码的居中计算;新增视口裁剪逻辑,只绘制当前画布可见范围内的网格线,提升大缩放比例下的绘制性能。
完整修改后代码
JavaScript 逻辑
//////////////////////////////////////////////////////////////////////////////// // 用户常量 const canvasWidth = 400; const canvasHeight = 200; const canvasBackground = '#282c34'; const gridCellColor = "#777"; const gridBlockColor = "#505050"; const axisColor = "white"; // 内部常量 const canvas = document.getElementById('canvas'); const context = canvas.getContext('2d', { alpha: false }); //////////////////////////////////////////////////////////////////////////////// // 用户可配置参数 const baseCellSize = 10; const cellBlock = 5; const minCellSize = 2; const maxCellSize = 200; // 内部状态变量 let grid; let currentCellSize = baseCellSize; // 视图偏移量,实现平移功能时直接修改这两个值即可 let viewOffsetX = canvasWidth / 2; let viewOffsetY = canvasHeight / 2; let mousePosX = 0; let mousePosY = 0; //////////////////////////////////////////////////////////////////////////////// // 网格类 class Grid{ constructor() { this.width = canvasWidth; this.height = canvasHeight; this.cellSize = currentCellSize; this.cellBlock = cellBlock; } draw(){ // 清空画布 context.fillStyle = canvasBackground; context.fillRect(0, 0, this.width, this.height); // 保存画布状态,应用当前视图偏移 context.save(); context.translate(viewOffsetX, viewOffsetY); // 计算当前视口内需要绘制的网格线范围,跳过视口外的线提升性能 const startX = Math.floor(-viewOffsetX / this.cellSize) - 1; const endX = Math.ceil((this.width - viewOffsetX) / this.cellSize) + 1; const startY = Math.floor(-viewOffsetY / this.cellSize) - 1; const endY = Math.ceil((this.height - viewOffsetY) / this.cellSize) + 1; // 绘制竖线 context.lineWidth = 0.5; for (let i = startX; i <= endX; i++) { context.strokeStyle = i % this.cellBlock === 0 ? gridCellColor : gridBlockColor; context.beginPath(); context.moveTo(this.cellSize * i, -viewOffsetY); context.lineTo(this.cellSize * i, this.height - viewOffsetY); context.stroke(); context.closePath(); } // 绘制横线 for (let i = startY; i <= endY; i++) { context.strokeStyle = i % this.cellBlock === 0 ? gridCellColor : gridBlockColor; context.beginPath(); context.moveTo(-viewOffsetX, this.cellSize * i); context.lineTo(this.width - viewOffsetX, this.cellSize * i); context.stroke(); context.closePath(); } // 绘制坐标轴,始终叠在网格线上方 context.lineWidth = 1.5; context.strokeStyle = axisColor; // X轴 context.beginPath(); context.moveTo(-viewOffsetX, 0); context.lineTo(this.width - viewOffsetX, 0); context.stroke(); context.closePath(); // Y轴 context.beginPath(); context.moveTo(0, -viewOffsetY); context.lineTo(0, this.height - viewOffsetY); context.stroke(); context.closePath(); context.restore(); } } //////////////////////////////////////////////////////////////////////////////// // 工具函数 function init() { // 适配高DPI屏幕 const dpr = window.devicePixelRatio || 1; canvas.width = canvasWidth * dpr; canvas.height = canvasHeight * dpr; context.scale(dpr, dpr); canvas.style.width = canvasWidth + "px"; canvas.style.height = canvasHeight + "px"; // 初始化并绘制网格 grid = new Grid(); grid.draw(); } function handleZoom(e){ e.preventDefault(); // 计算缩放后的单元格大小,限制缩放范围避免异常 const zoomDelta = -e.deltaY / 120 * 0.1 * currentCellSize; const newCellSize = Math.max(minCellSize, Math.min(maxCellSize, currentCellSize + zoomDelta)); if (newCellSize === currentCellSize) return; // 核心逻辑:计算偏移补偿,保证鼠标指向的网格点屏幕位置不变 const scale = newCellSize / currentCellSize; viewOffsetX = mousePosX - (mousePosX - viewOffsetX) * scale; viewOffsetY = mousePosY - (mousePosY - viewOffsetY) * scale; // 更新状态重绘 currentCellSize = newCellSize; grid.cellSize = currentCellSize; grid.draw(); } //////////////////////////////////////////////////////////////////////////////// // 初始化 init(); // 事件绑定 window.addEventListener("resize", init); // 滚轮缩放,使用标准wheel事件兼容现代浏览器 canvas.addEventListener('wheel', handleZoom, { passive: false }); // 实时获取鼠标在canvas内的本地坐标 canvas.addEventListener('mousemove', function (e) { const rect = canvas.getBoundingClientRect(); mousePosX = e.clientX - rect.left; mousePosY = e.clientY - rect.top; })
CSS 样式
html, body { background:#21252b; width:100%; height:100%; margin:0px; padding:0px; overflow: hidden; } span{ color:white; font-family: arial; font-size: 12px; margin:8px; } #canvas{ margin:8px; border: 1px solid white; }
HTML 结构
<span>Use mouse wheel to zoom</span> <canvas id="canvas"></canvas>
扩展说明
- 代码仅在可见视口范围内绘制网格线,相比原有全量绘制逻辑,在大缩放比例下性能表现更好
- 预留的
viewOffsetX、viewOffsetY变量可以直接对接平移功能:监听鼠标拖拽事件时,根据鼠标移动距离增减这两个值后重绘即可,不需要修改其他核心逻辑 - 缩放范围加了最小/最大值限制,避免缩放过小导致网格线过密、过大导致看不到参考线的异常情况
内容的提问来源于stack exchange,提问作者Bachir Messaouri
相关产品推荐
相关产品推荐

