Координаты курсора при масштабировании canvas js

При написании функции масштабирования внутри холста возникла проблема с расчётом координат. Если курсор находится в центре холста, масштабирование происходит равномерно во все стороны но если курсор в любой другой точке то изображение масштабируется некорректно и уходит в сторону. Как правильнее переопределить координаты таким образом, чтобы при любом положении курсора изображение масштабировалось от него равномерно во все стороны?

function scale() {
        var canvas = document.getElementById('canvas');
        var context = canvas.getContext('2d');
        const canvas_width = document.documentElement.scrollWidth;
        const canvas_height = document.documentElement.scrollHeight;

        canvas.addEventListener("wheel", onmousewheel, false);
        canvas.addEventListener("DOMMouseScroll", onmousewheel, false);
    
        var position = {x: 0, y: 0};
    
        function onmousewheel(event) {
            var x = event.clientX - position.x + canvas_width/2;
            var y = event.clientY - position.y + canvas_height/2;
            const delta = event.type === "wheel" ? event.wheelDelta : -event.detail;
            const increment = delta > 0 ? 1.1 : 0.9;
    
            const previous_scale = scale;
            scale *= increment;
    
            position.x -= x * (scale / previous_scale - 1);
            position.y -= y * (scale / previous_scale - 1);

            context.clearRect(0, 0, canvas.width, canvas.height);
            context.setTransform(scale, 0, 0, scale, position.x, position.y);
            CANVAS.draw(); // отдельная функция отрисовки всех элементов на холсте
            event.preventDefault();
        }
    }

Также интересует, как можно сделать анимацию холста в момент масштабирования более плавной и без резких рывков?

Заранее благодарю за ответ.


Ответы (1 шт):

Автор решения: DungyBug

Вероятно, это то, что вам нужно ( при условии, что scale() вызывается один раз для подписки на события wheel ):

function scale() {
    const canvasWidth = canvas.width;
    const canvasHeight = canvas.height;

    canvas.addEventListener("wheel", onmousewheel, false);
    canvas.addEventListener("DOMMouseScroll", onmousewheel, false);

    // Прямоугольник видимости или окно, в котором мы работаем
    const currentArea = {x1: 0, y1: 0, x2: canvasWidth, y2: canvasHeight};

    let scale = 1.0;

    function onmousewheel(event) {
        const mouseX = event.clientX;
        const mouseY = event.clientY;
        const delta = event.type === "wheel" ? event.wheelDelta : -event.detail;
        const increment = delta > 0 ? 1.1 : 0.9;

        scale *= increment;

        currentArea.x1 -= mouseX;
        currentArea.y1 -= mouseY;
        currentArea.x2 -= mouseX;
        currentArea.y2 -= mouseY;

        currentArea.x1 *= increment;
        currentArea.y1 *= increment;
        currentArea.x2 *= increment;
        currentArea.y2 *= increment;

        currentArea.x1 += mouseX;
        currentArea.y1 += mouseY;
        currentArea.x2 += mouseX;
        currentArea.y2 += mouseY;

        // canvasScale == scale, поэтому их можно использовать равнозначно
        const canvasScale = (currentArea.x2 - currentArea.x1) / canvasWidth;

        context.setTransform(1, 0, 0, 1, 0, 0); // Чтобы не оставалось следов от масштабирования
        context.clearRect(0, 0, canvasWidth, canvasHeight);
        context.setTransform(canvasScale, 0, 0, canvasScale, currentArea.x1, currentArea.y1);
        CANVAS.draw(); // отдельная функция отрисовки всех элементов на холсте
        event.preventDefault();
    }
}

Для того, чтобы сделать увеличение плавным, можно завести функцию, которая каждый кадр будет рисовать на холсте изображение. Тогда, при событии wheel мы можем рассчитать "желаемое" увеличение, а уже в рисующей функции плавно подходить к нему. Плавный подход уже можно сделать через интерполяцию:

// Вот это и есть интерполирующая функция
function lerp(a, b, x) {
    return a + (b - a) * x;
}

const canvasWidth = canvas.width;
const canvasHeight = canvas.height;

// Вот сюда будут записываться "желаемые" значения
const desiredArea = {x1: 0, y1: 0, x2: canvasWidth, y2: canvasHeight};

// А здесь будет храниться текущее окно ( увеличение )
const currentArea = {x1: 0, y1: 0, x2: canvasWidth, y2: canvasHeight};

function scale() {
    canvas.addEventListener("wheel", onmousewheel, false);
    canvas.addEventListener("DOMMouseScroll", onmousewheel, false);

    function onmousewheel(event) {
        const mouseX = event.clientX;
        const mouseY = event.clientY;
        const delta = event.type === "wheel" ? event.wheelDelta : -event.detail;
        const increment = delta > 0 ? 1.1 : 0.9;

        desiredArea.x1 -= mouseX;
        desiredArea.y1 -= mouseY;
        desiredArea.x2 -= mouseX;
        desiredArea.y2 -= mouseY;

        desiredArea.x1 *= increment;
        desiredArea.y1 *= increment;
        desiredArea.x2 *= increment;
        desiredArea.y2 *= increment;

        desiredArea.x1 += mouseX;
        desiredArea.y1 += mouseY;
        desiredArea.x2 += mouseX;
        desiredArea.y2 += mouseY;

        event.preventDefault();
    }
}

function draw() {
    // Вот тут можно поиграться с плавностью приближения, только значения должны быть
    // от 0 и до 1
    const lerpCoefficient = 0.5; // 0.5 даёт хорошее отношение между отзывчивостью и плавностью

    currentArea.x1 = lerp(currentArea.x1, desiredArea.x1, lerpCoefficient);
    currentArea.y1 = lerp(currentArea.y1, desiredArea.y1, lerpCoefficient);
    currentArea.x2 = lerp(currentArea.x2, desiredArea.x2, lerpCoefficient);
    currentArea.y2 = lerp(currentArea.y2, desiredArea.y2, lerpCoefficient);

    const canvasScale = (currentArea.x2 - currentArea.x1) / canvasWidth;

    context.setTransform(1, 0, 0, 1, 0, 0); // Чтобы не оставалось следов от масштабирования
    context.clearRect(0, 0, canvasWidth, canvasHeight);
    context.setTransform(canvasScale, 0, 0, canvasScale, currentArea.x1, currentArea.y1);
    CANVAS.draw(); // отдельная функция отрисовки всех элементов на холсте

    requestAnimationFrame(() => draw());
}

draw();

Это всё примеры реализаций, и при желании, я думаю, их можно будет оформить конкрентно под ваш случай ( например: вынести функционал увеличения и глобальные переменные currentArea и desiredArea куда-нибудь в отдельный класс, который будет заниматься отрисовкой ).

Посмотреть пример вживую можно ниже:

const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");

canvas.width = window.screen.width;
canvas.height = window.screen.height;

document.body.append(canvas);

const testImg = new Image();
testImg.src = "https://i.imgur.com/6yHmlwT.jpeg";

// Позиция курсора
let x = 0;
let y = 0;

const CANVAS = {
    draw: () => {
        if(context === null) {
            return;
        }
    
        context.fillStyle = "#000";
        context.fillRect(0, 0, 1920, 1080);
        
        const canvasScale = (currentArea.x2 - currentArea.x1) / canvasWidth;
        context.drawImage(testImg, 0, 0);
        
        context.fillStyle = "red";
        context.beginPath();
        
        // Преобразовываем позицию курсора
        context.arc((x - currentArea.x1) / canvasScale, (y - currentArea.y1) / canvasScale, 5, 0, 2*Math.PI);
        context.fill();
        context.closePath();
        
        context.strokeWidth = 2;
        context.strokeStyle = "#00f";
        context.strokeRect((100 - currentArea.x1) / canvasScale, (100 - currentArea.y1) / canvasScale, 100 / canvasScale, 50 / canvasScale);
    }
}

// Вот это и есть интерполирующая функция
function lerp(a, b, x) {
    return a + (b - a) * x;
}

const canvasWidth = canvas.width;
const canvasHeight = canvas.height;

// Вот сюда будут записываться "желаемые" значения
const desiredArea = {x1: 0, y1: 0, x2: canvasWidth, y2: canvasHeight};

// А здесь будет храниться текущее окно ( увеличение )
const currentArea = {x1: 0, y1: 0, x2: canvasWidth, y2: canvasHeight};

function scale() {
    canvas.addEventListener("wheel", onmousewheel, false);
    canvas.addEventListener("DOMMouseScroll", onmousewheel, false);

    function onmousewheel(event) {
        const mouseX = event.clientX;
        const mouseY = event.clientY;
        const delta = event.type === "wheel" ? event.wheelDelta : -event.detail;
        const increment = delta > 0 ? 1.1 : 0.9;

        x = mouseX;
        y = mouseY;

        desiredArea.x1 -= mouseX;
        desiredArea.y1 -= mouseY;
        desiredArea.x2 -= mouseX;
        desiredArea.y2 -= mouseY;

        desiredArea.x1 *= increment;
        desiredArea.y1 *= increment;
        desiredArea.x2 *= increment;
        desiredArea.y2 *= increment;

        desiredArea.x1 += mouseX;
        desiredArea.y1 += mouseY;
        desiredArea.x2 += mouseX;
        desiredArea.y2 += mouseY;

        event.preventDefault();
    }
}

function draw() {
    // Вот тут можно поиграться с плавностью приближения, только значения должны быть
    // от 0 и до 1
    const lerpCoefficient = 0.5; // 0.5 даёт хорошее отношение между отзывчивостью и плавностью

    currentArea.x1 = lerp(currentArea.x1, desiredArea.x1, lerpCoefficient);
    currentArea.y1 = lerp(currentArea.y1, desiredArea.y1, lerpCoefficient);
    currentArea.x2 = lerp(currentArea.x2, desiredArea.x2, lerpCoefficient);
    currentArea.y2 = lerp(currentArea.y2, desiredArea.y2, lerpCoefficient);

    const canvasScale = (currentArea.x2 - currentArea.x1) / canvasWidth;

    context.setTransform(1, 0, 0, 1, 0, 0); // Чтобы не оставалось следов от масштабирования
    context.clearRect(0, 0, canvasWidth, canvasHeight);
    context.setTransform(canvasScale, 0, 0, canvasScale, currentArea.x1, currentArea.y1);
    CANVAS.draw(); // отдельная функция отрисовки всех элементов на холсте

    requestAnimationFrame(() => draw());
}

document.addEventListener("mousemove", e => {
        x = e.clientX;
        y = e.clientY;
})

draw();

scale();
html, body {
    margin: 0;
    padding: 0;
    overflow: hidden;
}

→ Ссылка