14 декабря 2020

🔍 Масштабирование и панорамирование в 69 строчках JavaScript

Frontend-разработчик в Foquz. https://www.cat-in-web.ru/
Чтобы не использовать тяжеловесные библиотеки, можно написать на чистом JavaScript простое и расширяемое решение для манипуляций с элементами веб-страницы.
🔍 Масштабирование и панорамирование в 69 строчках JavaScript

Возможность масштабировать элементы страницы и детально рассматривать их – это очень крутой пользовательский опыт. Существует множество готовых библиотек с подобной функциональностью, но сегодня мы напишем собственный велосипед на чистом JavaScript! Зачем?

  • Сторонние решения часто предлагают избыточную функциональность, которая вам не нужна, но бандл приложения увеличивает.
  • К тому же это замечательный челлендж, который расшевелит ваш мозг и прокачает навыки программирования.

В результате мы получим очень маленькую (всего 69 строчек кода!), простую и удобную библиотечку для масштабирования и панорамирования.

Разметка и стили

Создадим HTML-страницу и разместим на ней элемент-контейнер (#container). Внутрь поместим рабочую область (.area), которую мы и будем непосредственно масштабировать и панорамировать.

index.html
        <div id="container">
  <div class="area">
      <div class="rectangle"></div>
      <div class="circle"></div>
      <div class="text-area">
          <h1>Example line of text</p>
      </div>
  </div>
</div>
    

Внутри рабочей области находятся несколько элементов, которые не несут никакой смысловой нагрузки, а просто предназначены для демонстрации работы кода.

Добавим также немного стилей для оформления страницы:

style.css
        body {
    overflow: hidden;
}

#container {
    height: 100%;
    width: 100%;
    position: absolute;
}

.area {
    border: 1px dashed black;
    height: 80%;
    width: 80%;
    position: absolute;
}

.circle {
    height: 200px;
    width: 200px;
    background-color: navajowhite;
    border-radius: 50%;
    display: inline-block;
    position: relative;
}

.rectangle {
    background-color: navajowhite;
    height: 150px;
    width: 250px;
    position: relative;
}

.text-area {
    float: right;
    position: relative;
}
    

Для body устанавливаем overflow: hidden. Это нужно, чтобы избежать переполнения страницы и появления прокрутки при чрезмерном увеличении элемента.

Также добавим рамку для визуального обозначения рабочей области (.area) и немного облагородим демо-контент (классы .circle, .rectangle и .text-area).

Скрипт библиотеки

Код самой библиотеки будет располагаться в файле renderer.js. Экспортируем из модуля главную функцию renderer:

renderer.js
        const renderer = ({ minScale, maxScale, element, scaleSensitivity = 10 }) => {
    const state = {
        element,
        minScale,
        maxScale,
        scaleSensitivity,
        transformation: {
            originX: 0,
            originY: 0,
            translateX: 0,
            translateY: 0,
            scale: 1
        },
    };
    return Object.assign({}, makeZoom(state), makePan(state));
};

module.exports = { renderer };
    

Она принимает базовые параметры:

  1. minScale – минимальный масштаб;
  2. maxScale – максимальный масштаб;
  3. element – DOM-элемент, с которым будут производиться манипуляции;
  4. scaleSensitivity – коэффициент чувствительность масштабирования, по умолчанию 10.

В замыкании функции создается объект состояния – state, который хранит настройки и совершенные над элементом преобразования (поле transformation).

Из функции возвращается объект с набором методов. При этом возможности масштабирования и панорамирования разделены на отдельные функции-конструкторы – makeZoom и makePan, которые мы разберем чуть позже. Конструкторы получают общий объект состояния и возвращают отдельный набор методов для взаимодействия с ним.

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

Трансформации

Все манипуляции с элементом будут производиться через изменение свойства transform. Для этого используем CSS-функцию matrix, которой нужно передать правильные параметры масштаба (scale) и сдвига (translateX и translateY):

renderer.js
        const getMatrix = ({ scale, translateX, translateY }) => 
  `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})`;
    

Вспомогательная функция getMatrix просто формирует шаблонную строку правильного формата, которую нужно установить в свойство style.transform элемента.

Панорамирование

При панорамировании должно изменяться положение элемента на странице, то есть производиться его сдвиг. Функция pan принимает текущее состояние элемента (state), а также новые координаты. Затем она обновляет состояние, прибавляя новый сдвиг к текущему положению и обновляет свойство style элемента.

renderer.js
        const pan = ({ state, originX, originY }) => {
    state.transformation.translateX += originX;
    state.transformation.translateY += originY;
    state.element.style.transform =
        getMatrix({ scale: state.transformation.scale, translateX: state.transformation.translateX, translateY: state.transformation.translateY });
};
    

Теперь реализуем два метода:

  1. panBy – простой сдвиг на указанные координаты;
  2. panTo – сдвиг с одновременным масштабированием.
renderer.js
        const makePan = (state) => ({
    panBy: ({ originX, originY }) => pan({ state, originX, originY }),
    panTo: ({ originX, originY, scale }) => {
        state.transformation.scale = scale;
        pan({ state, originX: originX - state.transformation.translateX, originY: originY - state.transformation.translateY });
    },
});

    

При сдвиге с масштабированием координаты элемента нужно скорректировать.

Масштабирование

Для изменения размера элемента нам потребуется несколько вспомогательных функций для расчетов:

renderer.js
        const hasPositionChanged = ({ pos, prevPos }) => 
    pos !== prevPos;

const valueInRange = ({ minScale, maxScale, scale }) => 
    scale <= maxScale && scale >= minScale;

const getTranslate = ({ minScale, maxScale, scale }) => ({ pos, prevPos, translate }) =>
   valueInRange({ minScale, maxScale, scale }) && hasPositionChanged({ pos, prevPos })
       ? translate + (pos - prevPos * scale) * (1 - 1 / scale)
       : translate;

const getScale = ({ scale, minScale, maxScale, scaleSensitivity, deltaScale }) => {
    let newScale = scale + (deltaScale / (scaleSensitivity / scale));
    newScale = Math.max(minScale, Math.min(newScale, maxScale));
    return [scale, newScale];
};
    

Метод getScale рассчитывает новый масштаб на основе предыдущего значения, минимального и максимального ограничений (minScale, maxScale) и коэффициентов (scaleSensitivity, deltaScale).

Метод getTranslate рассчитывает новый сдвиг на основе масштаба и текущей и предыдущей позиции.

А вот и реализация функции makeZoom:

renderer.js
        const makeZoom = (state) => ({
    zoom: ({ x, y, deltaScale }) => {
        const { left, top } = state.element.getBoundingClientRect();
        const { minScale, maxScale, scaleSensitivity } = state;
        const [ scale, newScale ] = getScale({ scale: state.transformation.scale, deltaScale, minScale, maxScale, scaleSensitivity });
        const originX = x - left;
        const originY = y - top;
        const newOriginX = originX / scale;
        const newOriginY = originY / scale;
        const translate = getTranslate({ scale, minScale, maxScale });
        const translateX = translate({ pos: originX, prevPos: state.transformation.originX, translate: state.transformation.translateX });
        const translateY = translate({ pos: originY, prevPos: state.transformation.originY, translate: state.transformation.translateY });

        state.element.style.transformOrigin = `${newOriginX}px ${newOriginY}px`;
        state.element.style.transform = getMatrix({ scale: newScale, translateX, translateY });
        state.transformation = { originX: newOriginX, originY: newOriginY, translateX, translateY, scale: newScale };
    }
});
    

Она возвращает только один метод zoom, предназначенный для масштабирования элемента. Он получает координаты курсора, а также параметр deltaScale – коэффициент, который определяет направление масштабирования (1 для увеличения, -1 для уменьшения).

Функция вычисляет новые параметры трансформации и обновляет свойство style элемента.

При масштабировании кроме style.transform нужно изменять также свойство style.transformOrigin, чтобы скорректировать позицию элемента. В качестве эксперимента вы можете закомментировать 14 строчку и посмотреть, что будет.

Главный файл

Кроме того мы сделаем главный файл приложения index.js:

index.js
        (() => {
    const { renderer } = require("./src/renderer");
    const container = document.getElementById("container");
    const instance = renderer({ minScale: .1, maxScale: 30, element: container.children[0], scaleSensitivity: 50 });
    container.addEventListener("wheel", (event) => {
        if (!event.ctrlKey) {
            return;
        }
        event.preventDefault();
        instance.zoom({
            deltaScale: Math.sign(event.deltaY) > 0 ? 1 : -1,
            x: event.pageX,
            y: event.pageY
        });
    });
    container.addEventListener("dblclick", () => {
        instance.panTo({
            originX: 0,
            originY: 0,
            scale: 1,
        });
    });
    container.addEventListener("mousemove", (event) => {
        if (!event.shiftKey) {
            return;
        }
        event.preventDefault();
        instance.panBy({
            originX: event.movementX,
            originY: event.movementY
        });
    })
})();
    

Клиентский код создает экземпляр renderer и передает ему базовую конфигурацию:

  • элемент, размер которого будет изменяться;
  • минимальный и максимальный масштаб;
  • коэффициент чувствительности масштабирования.

Затем устанавливаются слушатели событий мыши и в нужный момент вызываются нужные методы:

  • для масштабирования используйте колесико мыши или сенсорную панель, зажав клавишу CTRL.
  • для перемещения – перемещайте мышь или используйте тачпад, зажав клавишу SHIFT.
  • Двойной щелчок мыши восстановит исходное состояние элемента.

Демо-пример:

Тестирование

Проверим реализованные функции с помощью библиотеки Mocha:

renderer.testCases.js
        const panByTestCases = [
    {
        description: 'should pan by passed originX and originY (x: 100, y: 100)',
        minScale: .1,
        maxScale: 20,
        origins: [
            {
                originX: 100,
                originY: 100,
            }
        ],
        result: 'matrix(1, 0, 0, 1, 100, 100)'
    },
    {
        description: 'should pan by passed originX and originY (x: 50, y: 50)',
        minScale: .1,
        maxScale: 20,
        origins: [
            {
                originX: 100,
                originY: 100,
            },
            {
                originX: -50,
                originY: -50,
            }
        ],
        result: 'matrix(1, 0, 0, 1, 50, 50)'
    },
    {
        description: 'should pan by passed originX and originY (x: -50, y: 50)',
        minScale: .1,
        maxScale: 20,
        origins: [
            {
                originX: -100,
                originY: 100,
            },
            {
                originX: 50,
                originY: -50,
            }
        ],
        result: 'matrix(1, 0, 0, 1, -50, 50)'
    }
];

module.exports = {
    panByTestCases,
};
    
renderer.spec.js
        const assert = require('assert');
const { renderer } = require('../src/renderer');
const { panByTestCases } = require('./renderer.testCases');

describe('renderer', () => {
    let _element;
    beforeEach(() => {
        _element = {
            getBoundingClientRect: () => ({ left: 0, top: 0 }),
            style: {
                transform: "",
                transformOrigin: "",
            }
        }
    });
    describe('#canPan()', () => {
        describe('#panBy()', () => {
            panByTestCases.forEach(({ description, minScale, maxScale, origins, result }) =>
                it(description, () => {
                    const instance = renderer({ minScale, maxScale, element: _element })
                    origins.forEach(({ originX, originY }) => instance.panBy({ originX, originY }))
                    assert.equal(_element.style.transform, result);
                }),
            );
        });
    });
    

Перед каждым тестом (beforeEach) создается объект _element с дефолтными значениями.

Кейсы тестирования для удобства вынесены в отдельный файл renderer.testCases.js.

***

В итоге у нас получился очень простой и удобный инструмент для масштабирования и панорамирования на JavaScript, состоящий всего из 69 строк кода. Его можно сократить больше, но не хочется терять читабельность.

Полную и минифицированную версии ищите в репозитории проекта.

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Продуктовый аналитик в поддержку
по итогам собеседования
Golang разработчик (middle)
от 230000 RUB до 300000 RUB
Аналитик данных
Екатеринбург, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ