You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

原生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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 05:45:06