Drag в сочетании с zoom

Одновременная работа drag и zoom — одна из наиболее важных техник при создании интерактивных визуализаций. Такая комбинация используется в:

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

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


Архитектура взаимодействия

Обычно используются два уровня:

  1. Контейнер сцены — масштабируется и перемещается через zoom.
  2. Отдельные элементы — перемещаются через drag.

Типичная структура SVG:

<svg width="800" height="600">
    <g class="viewport">
        <circle />
        <circle />
        <circle />
    </g>
</svg>
  • svg принимает zoom-события.
  • g.viewport получает трансформации.
  • Внутренние элементы поддерживают drag.

Подключение drag и zoom

Базовая настройка zoom

const svg = d3.select("svg");

const viewport = svg.append("g")
    .attr("class", "viewport");

const zoom = d3.zoom()
    .scaleExtent([0.5, 5])
    .on("zoom", (event) => {
        viewport.attr("transform", event.transform);
    });

svg.call(zoom);

Добавление элементов

const circles = viewport.selectAll("circle")
    .data([1, 2, 3])
    .enter()
    .append("circle")
    .attr("cx", (d, i) => i * 120 + 100)
    .attr("cy", 200)
    .attr("r", 40);

Добавление drag

const drag = d3.drag()
    .on("drag", function(event) {
        d3.select(this)
            .attr("cx", event.x)
            .attr("cy", event.y);
    });

circles.call(drag);

После этого:

  • колесо мыши масштабирует сцену;
  • перетаскивание двигает отдельные круги.

Проблема конфликта событий

Без дополнительных настроек могут возникнуть проблемы:

  • drag запускает zoom;
  • zoom мешает drag;
  • происходят скачки координат;
  • элемент “убегает” при увеличении масштаба.

Причина — обе системы используют pointer-события.


Разделение областей ответственности

Наиболее распространённая схема:

Объект Поведение
SVG zoom
Элементы drag

Пример:

svg.call(zoom);

circles.call(drag);

Такой подход уже предотвращает большую часть конфликтов.


Остановка всплытия событий

Иногда zoom всё равно реагирует на drag.

Используется остановка propagation:

const drag = d3.drag()
    .on("start", function(event) {
        event.sourceEvent.stopPropagation();
    })
    .on("drag", function(event) {
        d3.select(this)
            .attr("cx", event.x)
            .attr("cy", event.y);
    });

Теперь drag не передаёт события zoom-поведению.


Работа transform при zoom

Во время масштабирования D3 формирует объект:

event.transform

Он содержит:

{
    x,
    y,
    k
}

Где:

  • x — смещение по X;
  • y — смещение по Y;
  • k — коэффициент масштаба.

Применение transform

viewport.attr("transform", event.transform);

D3 автоматически преобразует объект в строку:

translate(x, y) scale(k)

Drag внутри масштабированной сцены

Наиболее распространённая ошибка — неправильные координаты при zoom.

Например:

.attr("cx", event.x)
.attr("cy", event.y)

После увеличения масштаба координаты становятся некорректными.

Причина — drag работает в экранных координатах, а SVG уже трансформирован.


Корректировка координат с transform.invert

Необходимо переводить координаты мыши в систему координат сцены.

Получение текущего transform

const transform = d3.zoomTransform(svg.node());

Инвертирование координат

const point = transform.invert([event.x, event.y]);

Теперь:

point[0]
point[1]

содержат реальные координаты сцены.


Правильный drag при zoom

const drag = d3.drag()
    .on("drag", function(event) {

        const transform = d3.zoomTransform(svg.node());

        const [x, y] = transform.invert([event.x, event.y]);

        d3.select(this)
            .attr("cx", x)
            .attr("cy", y);
    });

Drag с сохранением смещения

Без offset элемент может “прыгать” к курсору.


Запоминание начальной позиции

const drag = d3.drag()
    .on("start", function(event, d) {

        d.offsetX = d3.select(this).attr("cx") - event.x;
        d.offsetY = d3.select(this).attr("cy") - event.y;
    })
    .on("drag", function(event, d) {

        d3.select(this)
            .attr("cx", event.x + d.offsetX)
            .attr("cy", event.y + d.offsetY);
    });

Drag с учётом zoom и offset

Полноценная версия:

const drag = d3.drag()

    .on("start", function(event, d) {

        const transform = d3.zoomTransform(svg.node());

        const [x, y] = transform.invert([event.x, event.y]);

        d.offsetX = +d3.select(this).attr("cx") - x;
        d.offsetY = +d3.select(this).attr("cy") - y;
    })

    .on("drag", function(event, d) {

        const transform = d3.zoomTransform(svg.node());

        const [x, y] = transform.invert([event.x, event.y]);

        d3.select(this)
            .attr("cx", x + d.offsetX)
            .attr("cy", y + d.offsetY);
    });

Ограничение масштаба

Для предотвращения чрезмерного zoom:

const zoom = d3.zoom()
    .scaleExtent([0.5, 8]);

Ограничение перемещения

Можно ограничить область панорамирования:

.zoom()
.translateExtent([
    [0, 0],
    [2000, 2000]
]);

Ограничение viewport

.extent([
    [0, 0],
    [800, 600]
]);

Отключение wheel zoom

Иногда масштабирование колесом мешает drag.

svg.call(
    zoom.filter((event) => {
        return event.type !== "wheel";
    })
);

Выборочная активация zoom

Например, zoom только с зажатым Ctrl:

const zoom = d3.zoom()
    .filter((event) => {
        return event.ctrlKey;
    });

Drag только левой кнопкой мыши

const drag = d3.drag()
    .filter((event) => event.button === 0);

Масштабирование относительно курсора

D3 автоматически масштабирует относительно позиции курсора.

Это одна из причин популярности d3.zoom() по сравнению с ручной реализацией transform.


Центрирование сцены

svg.call(
    zoom.transform,
    d3.zoomIdentity.translate(200, 100).scale(2)
);

Сброс zoom

svg.transition()
    .duration(500)
    .call(
        zoom.transform,
        d3.zoomIdentity
    );

Drag узлов force simulation

Один из важнейших сценариев — совмещение drag, zoom и force layout.


Базовая структура

const simulation = d3.forceSimulation(nodes)
    .force("charge", d3.forceManyBody())
    .force("center", d3.forceCenter(400, 300));

Drag для force graph

const drag = d3.drag()

    .on("start", dragStarted)
    .on("drag", dragged)
    .on("end", dragEnded);

Реализация обработчиков

function dragStarted(event, d) {

    if (!event.active) {
        simulation.alphaTarget(0.3).restart();
    }

    d.fx = d.x;
    d.fy = d.y;
}

function dragged(event, d) {

    d.fx = event.x;
    d.fy = event.y;
}

function dragEnded(event, d) {

    if (!event.active) {
        simulation.alphaTarget(0);
    }

    d.fx = null;
    d.fy = null;
}

Совмещение force graph и zoom

svg.call(zoom);

node.call(drag);

Zoom применяется к контейнеру:

const graph = svg.append("g");

Zoom для Canvas

D3 работает не только с SVG.


Transform для canvas

context.translate(transform.x, transform.y);
context.scale(transform.k, transform.k);

redraw после zoom

zoom.on("zoom", (event) => {

    transform = event.transform;

    render();
});

Drag на canvas

Canvas не хранит DOM-элементы, поэтому необходимо:

  1. искать объект вручную;
  2. хранить координаты;
  3. реализовывать hit testing.

Hit detection

function findNode(x, y) {

    return nodes.find(node => {

        const dx = x - node.x;
        const dy = y - node.y;

        return dx * dx + dy * dy < 400;
    });
}

Использование pointer-событий

Современные версии D3 используют Pointer Events.

Это обеспечивает:

  • поддержку touch;
  • поддержку stylus;
  • единый API;
  • корректную работу multitouch.

Pinch zoom

На мобильных устройствах d3.zoom() автоматически поддерживает:

  • pinch;
  • pan;
  • multitouch gestures.

Дополнительная реализация обычно не требуется.


Отключение двойного клика

svg.call(zoom)
    .on("dblclick.zoom", null);

Анимация zoom

svg.transition()
    .duration(750)
    .call(
        zoom.scaleTo,
        3
    );

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

svg.transition()
    .call(
        zoom.translateTo,
        500,
        300
    );

Комбинация translateTo и scaleTo

svg.transition()
    .duration(800)
    .call(zoom.translateTo, 400, 300)
    .call(zoom.scaleTo, 2);

Проблема производительности

При большом количестве элементов drag + zoom может вызывать:

  • лаги;
  • переполнение layout;
  • дорогие repaint;
  • падение FPS.

Оптимизация

Использование transform вместо изменения координат

Плохо:

.attr("cx", x)
.attr("cy", y)

Лучше:

.attr("transform", `translate(${x}, ${y})`)

requestAnimationFrame

let frameRequested = false;

zoom.on("zoom", (event) => {

    if (!frameRequested) {

        requestAnimationFrame(() => {

            viewport.attr("transform", event.transform);

            frameRequested = false;
        });

        frameRequested = true;
    }
});

Canvas для больших сцен

SVG плохо масштабируется при десятках тысяч объектов.

В таких случаях используют:

  • D3 + Canvas;
  • WebGL;
  • гибридный рендеринг.

Разделение интерактивности

Часто применяют:

Слой Назначение
Canvas Рендер
SVG Интерактивность
HTML Tooltip/UI

Координатные системы

При работе drag + zoom существует три системы координат:

Система Описание
Screen Координаты курсора
SVG Координаты viewport
Data Координаты данных

Ошибки почти всегда связаны с неправильным преобразованием между ними.


zoomIdentity

d3.zoomIdentity

Начальное transform-состояние:

{
    x: 0,
    y: 0,
    k: 1
}

transform.apply

Преобразование координат:

const screenX = transform.applyX(dataX);
const screenY = transform.applyY(dataY);

transform.invert

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

const dataX = transform.invertX(screenX);
const dataY = transform.invertY(screenY);

Практическая схема организации проекта

Структура

svg
└── viewport
    ├── links
    ├── nodes
    └── labels

Поведения

Элемент Поведение
svg zoom
nodes drag
viewport transform

Полный пример drag + zoom

const svg = d3.select("svg");

const viewport = svg.append("g");

const zoom = d3.zoom()
    .scaleExtent([0.5, 4])
    .on("zoom", (event) => {
        viewport.attr("transform", event.transform);
    });

svg.call(zoom);

const data = [
    { x: 100, y: 100 },
    { x: 300, y: 200 },
    { x: 500, y: 300 }
];

const drag = d3.drag()

    .on("start", function(event) {
        event.sourceEvent.stopPropagation();
    })

    .on("drag", function(event, d) {

        const transform = d3.zoomTransform(svg.node());

        const [x, y] = transform.invert([event.x, event.y]);

        d.x = x;
        d.y = y;

        d3.select(this)
            .attr("cx", d.x)
            .attr("cy", d.y);
    });

viewport.selectAll("circle")
    .data(data)
    .enter()
    .append("circle")
    .attr("cx", d => d.x)
    .attr("cy", d => d.y)
    .attr("r", 30)
    .call(drag);

Типичные ошибки

Drag применяется к контейнеру viewport

Это приводит к конфликту с zoom.


Отсутствие invert()

Координаты ломаются после масштабирования.


Изменение координат без transform

Возникают скачки объектов.


Zoom на внутренних элементах

Zoom должен быть привязан к корневому контейнеру.


Постоянный redraw SVG

Сильно ухудшает производительность.


Наиболее устойчивая стратегия

  1. Zoom управляет только контейнером сцены.
  2. Drag управляет только отдельными элементами.
  3. Все координаты проходят через transform.invert.
  4. События drag изолируются через stopPropagation.
  5. Для больших сцен используется Canvas или WebGL.