不 ctx.scale 缩放 canvas 到鼠标光标

Zoom canvas to mouse cursor without ctx.scale

我正在尝试将缩放原点更改为鼠标光标而不是默认的左上角,并且我无法将 ctx.translate 与 ctx.scale 一起使用,因为需要重新绘制网格缓冲区并且无法缩放(一行必须始终为 1 像素宽)。网格可以缩放和移动,只是原点不对。

不明白缩放后网格的新x坐标和新y坐标如何计算

重要的代码和我已经尝试过的代码在相机中有评论class。

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// utils //
function getCursorPos(evt) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: Math.floor(((evt.clientX - rect.left) / (rect.right - rect.left)) * canvas.offsetWidth),
        y: Math.floor(((evt.clientY - rect.top) / (rect.bottom - rect.top)) * canvas.offsetHeight),
    };
}
//////////

const scene = {
    renderer: canvas,
    context: ctx,
    width: 1200,
    height: 1000,
    cellSize: 30,
    render: function (buffer, x, y) {
        this.context.clearRect(0, 0, this.renderer.width, this.renderer.height);
        this.context.drawImage(buffer, x, y);
    },
};

class Grid {
    constructor() {
        this.width = scene.width;
        this.height = scene.height;
        this.cellSize = scene.cellSize;
        this.color = "black";
        this.buffer = document.createElement("canvas");
        this.buffer.width = this.width;
        this.buffer.height = this.height;
    }

    build() {
        // we don't directly make the draw calls on the main canvas (scene.renderer) ,
        // instead we create a buffer (a canvas element in this case),
        // which will be drawn as an image on the main canvas when we call scene.render();
        const ctx = this.buffer.getContext("2d");
        ctx.clearRect(0, 0, this.buffer.width, this.buffer.height);
        ctx.setLineDash([2, 5]);

        for (let u = 0, len = this.height; u < len; u += this.cellSize) {
            ctx.beginPath();
            ctx.moveTo(0.5, u + 0.5);
            ctx.lineTo(0.5 + this.width, u + 0.5);
            ctx.stroke();
        }

        for (let u = 0, len = this.width; u < len; u += this.cellSize) {
            ctx.beginPath();
            ctx.moveTo(u + 0.5, 0.5);
            ctx.lineTo(u + 0.5, 0.5 + this.height);
            ctx.stroke();
        }
    }

    setDimensions(w, h) {
        this.width = w;
        this.height = h;
    }

    getDimensions() {
        return { gw: this.width, gh: this.height };
    }

    setCellSize(size) {
        this.cellSize = size;
    }

    getCellSize() {
        return this.cellSize;
    }

    getBuffer() {
        return this.buffer;
    }
}

class Camera {
    constructor() {
        this.x = 0;
        this.y = 0;
        this.startDrag = null;
        this.zoom = 1;
        this.zoomInc = 0.05;
    }

    // converts screen coordinates to world coordinates
    toWorld(number) {
        return Math.floor(number / this.zoom);
    }

    toScreen(number) {
        return Math.floor(number / this.zoom);
    }

    setStartDrag(coord) {
        this.startDrag = { x: this.x + coord.x, y: this.y + coord.y };
    }

    isStartedDrag() {
        return !!this.startDrag;
    }

    drag(coord) {
        this.x = this.startDrag.x - coord.x;
        this.y = this.startDrag.y - coord.y;
    }

    stopDrag() {
        this.startDrag = null;
    }

    // the bit of code I can't figure //
    setScale({ x, y, deltaY }) {
        const step = deltaY > 0 ? -this.zoomInc : this.zoomInc;
        this.zoom += step;

        // this.x and this.y is where the grid is going to be rendered on the canvas;

        // first I thought about doing it this way :
        //this.x = -this.toScreen(this.toWorld(x) - x);
        //this.y = -this.toScreen(this.toWorld(y) - y);
        // but it only work if the grid is at x: 0 y: 0;

        // after some research I tried to shift x and y relatively to the cursor world position in the grid;
        //const worldPos = { x: this.toWorld(x) - this.x, y: this.toWorld(y) - this.y };
        //this.x = -(this.x - worldPos.x * step);
        //this.y = -(this.y - worldPos.y * step);

        // if x and y aren't changed the zoom origin defaults to the current origin of the camera;
    }

    getZoom() {
        return this.zoom;
    }
}

function init() {
    // initial setup //
    const grid = new Grid();
    const camera = new Camera();
    grid.build();
    const gridBuffer = grid.getBuffer();
    scene.context.drawImage(gridBuffer, 0, 0);

    scene.renderer.addEventListener("mousemove", (evt) => {
        if (camera.isStartedDrag()) {
            camera.drag(getCursorPos(evt));
            scene.render(gridBuffer, -camera.x, -camera.y);
        }
    });

    scene.renderer.addEventListener("mousedown", (evt) => {
        camera.setStartDrag(getCursorPos(evt));
    });

    scene.renderer.addEventListener("mouseup", () => {
        camera.stopDrag();
    });

    scene.renderer.addEventListener("wheel", (evt) => {
        evt.preventDefault();
        camera.setScale(evt);
        const zoom = camera.getZoom();
        grid.setCellSize(scene.cellSize * zoom);
        grid.setDimensions(scene.width * zoom, scene.height * zoom);

        // we rebuild a smaller or bigger grid according to the new zoom level;
        grid.build();
        const gridBuffer = grid.getBuffer();
        scene.render(gridBuffer, -camera.x, -camera.y);
    });
}

init();
    <html lang="en">
    <head>
        <script defer src="main.js"></script>
    </head>
    <body>
        <canvas id="canvas" width="800" height="600" style="border: 1px solid black"></canvas>
    </body>
    </html>

这是一个 fiddle : https://jsbin.com/wecupoxefe/edit?html,js,output

我最近写了一个简单的工具来显示有关意大利 covid-19 数据的图表,它完全可以解决您的问题,我可以说有很多方面需要注意。我的图表和你要找的唯一区别似乎是在我的图表中水平缩放和垂直缩放是独立的。

这是 chart,您可以检查它是否满足您的需要。

如果这正是您要查找的内容,我建议查看 SurfaceChart class,它处理缩放(基于鼠标或触摸位置)、图表拖动(使用触摸和鼠标)以及 1px 宽度的网格.我们谈论的是大约 300 行代码,所以我不会解释它们,我建议您看一下,如果您需要澄清,请随时询问。

解释在代码中:

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// utils //
function getCursorPos(evt) {
    const rect = canvas.getBoundingClientRect();
    return {
        x: Math.floor(((evt.clientX - rect.left) / (rect.right - rect.left)) * canvas.offsetWidth),
        y: Math.floor(((evt.clientY - rect.top) / (rect.bottom - rect.top)) * canvas.offsetHeight),
    };
}
//////////

const scene = {
    renderer: canvas,
    context: ctx,
    width: 1200,
    height: 1000,
    cellSize: 30,
    render: function (buffer, x, y) {
        this.context.clearRect(0, 0, this.renderer.width, this.renderer.height);
        this.context.drawImage(buffer, x, y);
    },
};

class Grid {
    constructor() {
        this.width = scene.width;
        this.height = scene.height;
        this.cellSize = scene.cellSize;
        this.color = "black";
        this.buffer = document.createElement("canvas");
        this.buffer.width = this.width;
        this.buffer.height = this.height;
    }

    build() {
        // we don't directly make the draw calls on the main canvas (scene.renderer) ,
        // instead we create a buffer (a canvas element in this case),
        // which will be drawn as an image on the main canvas when we call scene.render();
        const ctx = this.buffer.getContext("2d");
        ctx.clearRect(0, 0, this.buffer.width, this.buffer.height);
        ctx.setLineDash([2, 5]);

        for (let u = 0, len = this.height; u < len; u += this.cellSize) {
            ctx.beginPath();
            ctx.moveTo(0.5, u + 0.5);
            ctx.lineTo(0.5 + this.width, u + 0.5);
            ctx.stroke();
        }

        for (let u = 0, len = this.width; u < len; u += this.cellSize) {
            ctx.beginPath();
            ctx.moveTo(u + 0.5, 0.5);
            ctx.lineTo(u + 0.5, 0.5 + this.height);
            ctx.stroke();
        }
    }

    setDimensions(w, h) {
        this.buffer.width = this.width = w; // GT
        this.buffer.height = this.height = h; // GT
    }

    getDimensions() {
        return { gw: this.width, gh: this.height };
    }

    setCellSize(size) {
        this.cellSize = size;
    }

    getCellSize() {
        return this.cellSize;
    }

    getBuffer() {
        return this.buffer;
    }
}

class Camera {
    constructor() {
        this.x = 0;
        this.y = 0;
        this.startDrag = null;
        this.zoom = 1;
        this.zoomInc = 0.05;
    }

    // converts screen coordinates to world coordinates
    toWorld(number) {
        return Math.floor(number / this.zoom);
    }

    toScreen(number) {
        return Math.floor(number / this.zoom);
    }

    setStartDrag(coord) {
        this.startDrag = { x: this.x + coord.x, y: this.y + coord.y };
    }

    isStartedDrag() {
        return !!this.startDrag;
    }

    drag(coord) {
        this.x = this.startDrag.x - coord.x;
        this.y = this.startDrag.y - coord.y;
    }

    stopDrag() {
        this.startDrag = null;
    }

    // the bit of code I can't figure //
    setScale({ x, y, deltaY }) {
        const step = deltaY > 0 ? -this.zoomInc : this.zoomInc;
        if (this.zoom + step <= 0) return // for extra credit ;)
        // Fix x,y:
        x -= canvas.offsetLeft
        y -= canvas.offsetTop
        const zoom = this.zoom // old zoom
        this.zoom += step;
        /* We want in-world coordinates to remain the same:
         * (x + this.x')/this.zoom = (x + this.x)/zoom
         * (y + this.y')/this.zoom = (y + this.y)/zoom
         * =>
         */
         this.x = (x + this.x)*this.zoom/zoom - x
         this.y = (y + this.y)*this.zoom/zoom - y

        // this.x and this.y is where the grid is going to be rendered on the canvas;

        // first I thought about doing it this way :
        //this.x = -this.toScreen(this.toWorld(x) - x);
        //this.y = -this.toScreen(this.toWorld(y) - y);
        // but it only work if the grid is at x: 0 y: 0;

        // after some research I tried to shift x and y relatively to the cursor world position in the grid;
        //const worldPos = { x: this.toWorld(x) - this.x, y: this.toWorld(y) - this.y };
        //this.x = -(this.x - worldPos.x * step);
        //this.y = -(this.y - worldPos.y * step);

        // if x and y aren't changed the zoom origin defaults to the current origin of the camera;
    }

    getZoom() {
        return this.zoom;
    }
}

function init() {
    // initial setup //
    const grid = new Grid();
    const camera = new Camera();
    grid.build();
    const gridBuffer = grid.getBuffer();
    scene.context.drawImage(gridBuffer, 0, 0);

    scene.renderer.addEventListener("mousemove", (evt) => {
        if (camera.isStartedDrag()) {
            camera.drag(getCursorPos(evt));
            scene.render(gridBuffer, -camera.x, -camera.y);
        }
    });

    scene.renderer.addEventListener("mousedown", (evt) => {
        camera.setStartDrag(getCursorPos(evt));
    });

    scene.renderer.addEventListener("mouseup", () => {
        camera.stopDrag();
    });

    scene.renderer.addEventListener("wheel", (evt) => {
        evt.preventDefault();
        camera.setScale(evt);
        const zoom = camera.getZoom();
        grid.setCellSize(scene.cellSize * zoom);
        grid.setDimensions(scene.width * zoom, scene.height * zoom);

        // we rebuild a smaller or bigger grid according to the new zoom level;
        grid.build();
        const gridBuffer = grid.getBuffer();
        scene.render(gridBuffer, -camera.x, -camera.y);
    });
}

init();
    <html lang="en">
    <head>
        <script defer src="main.js"></script>
    </head>
    <body>
        <canvas id="canvas" width="800" height="600" style="border: 1px solid black"></canvas>
    </body>
    </html>