Drag на сенсорных экранах

Механизм перетаскивания в D3.js строится вокруг поведения d3.drag(), однако сенсорные экраны существенно отличаются от работы с мышью:

  • отсутствует состояние hover;
  • касание одновременно используется и для прокрутки страницы;
  • события могут содержать несколько точек контакта;
  • браузер добавляет жесты масштабирования и инерции;
  • частота генерации touch-событий отличается от mouse-событий.

На мобильных устройствах drag-интерфейсы требуют дополнительной настройки, иначе возникают:

  • рывки анимации;
  • конфликт со scroll;
  • случайное масштабирование страницы;
  • задержка реакции;
  • потеря контроля над указателем.

Подключение drag-поведения

Базовая настройка drag на SVG-элементе:

<svg width="600" height="400"></svg>
const svg = d3.select("svg");

const circle = svg.append("circle")
    .attr("cx", 200)
    .attr("cy", 200)
    .attr("r", 40)
    .attr("fill", "steelblue");

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

function dragStarted(event) {
    d3.select(this)
        .attr("stroke", "black")
        .attr("stroke-width", 3);
}

function dragged(event) {
    d3.select(this)
        .attr("cx", event.x)
        .attr("cy", event.y);
}

function dragEnded(event) {
    d3.select(this)
        .attr("stroke", null);
}

D3 автоматически поддерживает:

  • mouse events;
  • touch events;
  • pointer events.

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


Как D3 обрабатывает touch-события

При касании экрана D3 преобразует touch-взаимодействие в drag-поток:

Touch-событие Drag-событие D3
touchstart start
touchmove drag
touchend end

Объект event содержит:

event.x
event.y
event.dx
event.dy
event.identifier

На сенсорных устройствах особенно важен identifier.

Пример:

function dragged(event) {
    console.log(event.identifier);
}

identifier позволяет различать несколько одновременных касаний.


Проблема прокрутки страницы

Главная сложность touch drag — конфликт между:

  • вертикальной прокруткой;
  • горизонтальным скроллом;
  • жестом перетаскивания.

Без настройки браузер может интерпретировать движение пальца как scroll.

Решение через touch-action

Наиболее важное CSS-свойство:

circle {
    touch-action: none;
}

Либо:

svg {
    touch-action: none;
}

Это отключает стандартные touch-жесты браузера:

  • scroll;
  • pinch zoom;
  • double tap zoom.

Без touch-action: none drag на мобильных устройствах часто работает нестабильно.


Ограничение touch-action

Полное отключение touch-жестов не всегда желательно.

Например:

touch-action: pan-y;

Разрешает вертикальный scroll, но блокирует горизонтальные жесты.

Варианты:

Значение Поведение
none Полное отключение жестов
pan-x Только горизонтальный scroll
pan-y Только вертикальный scroll
manipulation Разрешение базовых жестов

Использование Pointer Events

Современные браузеры объединяют:

  • mouse;
  • touch;
  • stylus.

D3 использует Pointer Events, поэтому drag работает единообразно.

Пример pointer-информации:

function dragged(event) {
    console.log(event.pointerType);
}

Возможные значения:

mouse
touch
pen

Это позволяет создавать разные сценарии поведения.


Разделение логики для touch и mouse

Иногда требуется отдельная обработка сенсорного ввода.

Пример:

function dragged(event) {

    if (event.pointerType === "touch") {
        d3.select(this)
            .attr("fill", "orange");
    }

    if (event.pointerType === "mouse") {
        d3.select(this)
            .attr("fill", "steelblue");
    }

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

Повышение отзывчивости drag

На touch-экранах задержки особенно заметны.

Плохая практика:

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

    heavyCalculation();

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

Тяжёлые вычисления внутри drag-цикла вызывают:

  • потерю кадров;
  • лаги;
  • дрожание объектов.

Оптимизация

Следует:

  • минимизировать DOM-операции;
  • избегать reflow;
  • не выполнять сложные вычисления;
  • обновлять только изменённые элементы.

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

На мобильных устройствах transform обычно работает плавнее.

Менее эффективно:

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

Более эффективно:

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

Пример:

function dragged(event) {

    d3.select(this)
        .attr(
            "transform",
            `translate(${event.x}, ${event.y})`
        );
}

Ограничение области перетаскивания

На touch-экранах пользователь может легко вывести объект за границы сцены.

Ограничение координат:

function dragged(event) {

    const x = Math.max(0, Math.min(600, event.x));
    const y = Math.max(0, Math.min(400, event.y));

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

Drag контейнера

Часто перетаскивается не отдельный элемент, а группа.

Пример:

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

group.append("rect")
    .attr("width", 120)
    .attr("height", 60);

group.append("text")
    .text("Node")
    .attr("x", 20)
    .attr("y", 35);
group.call(
    d3.drag()
        .on("drag", function(event) {

            d3.select(this)
                .attr(
                    "transform",
                    `translate(${event.x}, ${event.y})`
                );
        })
);

Такой подход особенно полезен для:

  • диаграмм;
  • графов;
  • canvas-like интерфейсов;
  • node editor;
  • mind map.

Увеличение зоны касания

На сенсорных устройствах мелкие элементы неудобны.

Плохой пример:

.attr("r", 5)

Палец перекрывает маленький объект.

Невидимая зона захвата

group.append("circle")
    .attr("r", 30)
    .attr("fill", "transparent")
    .style("pointer-events", "all");

Визуальный элемент может оставаться маленьким, но touch-область становится удобной.


Работа с force simulation

Drag часто используется вместе с d3-force.

Пример:

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

node.call(drag);
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;
}

На touch-экранах это особенно важно, поскольку узлы графа иначе начинают «убегать» от пальца.


Подавление случайных кликов

После drag мобильные браузеры могут генерировать click.

Это вызывает проблемы:

  • открытие ссылок;
  • случайное нажатие;
  • запуск обработчиков.

D3 предоставляет:

event.sourceEvent

Пример:

function dragEnded(event) {

    event.sourceEvent.stopPropagation();
}

Использование drag.filter()

Метод filter() позволяет определить, какие события активируют drag.

Пример:

const drag = d3.drag()
    .filter(event => {

        return event.pointerType !== "mouse"
            || event.button === 0;
    });

Здесь:

  • touch разрешён всегда;
  • для мыши допускается только левая кнопка.

Мультитач

D3 поддерживает несколько одновременных касаний.

Пример отслеживания:

function dragged(event) {

    console.log(
        event.identifier,
        event.x,
        event.y
    );
}

Однако стандартный drag чаще всего предполагает один активный объект.

Для мультитач-интерфейсов обычно требуется собственная логика:

  • pinch zoom;
  • rotate;
  • multi-select;
  • canvas navigation.

Совместное использование drag и zoom

Типичный мобильный интерфейс:

  • один палец — drag;
  • два пальца — zoom.

Пример:

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

svg.call(zoom);

Конфликт drag и zoom необходимо контролировать.

Подход через фильтрацию

drag.filter(event => {

    return event.pointerType !== "touch"
        || event.touches?.length < 2;
});

Drag внутри Canvas

D3 может использоваться не только с SVG.

Для Canvas drag выглядит иначе:

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

canvas.call(
    d3.drag()
        .on("drag", dragged)
);

Но обновление выполняется вручную:

function dragged(event) {

    object.x = event.x;
    object.y = event.y;

    render();
}

На мобильных устройствах Canvas часто производительнее SVG при большом числе объектов.


Высокочастотные touch-события

Touch-события могут генерироваться очень часто.

Полезно применять throttling.

Пример:

let lastTime = 0;

function dragged(event) {

    const now = performance.now();

    if (now - lastTime < 16) {
        return;
    }

    lastTime = now;

    update(event);
}

16 мс соответствует примерно 60 FPS.


requestAnimationFrame

Ещё более эффективный подход — синхронизация с рендерингом браузера.

let pending = false;

function dragged(event) {

    if (pending) return;

    pending = true;

    requestAnimationFrame(() => {

        d3.select("#node")
            .attr("cx", event.x)
            .attr("cy", event.y);

        pending = false;
    });
}

Такой подход уменьшает нагрузку на мобильный GPU и CPU.


Пассивные обработчики

Браузеры оптимизируют touch-события через passive listeners.

Однако drag-интерфейсы иногда требуют блокировки стандартного поведения.

D3 автоматически управляет обработчиками, но при ручной работе важно помнить:

event.preventDefault();

может не работать внутри passive listener.


Drag и инерция

Мобильные интерфейсы часто используют инерционное движение.

D3 не реализует inertia автоматически, но её можно добавить.

Пример хранения скорости:

let vx = 0;
let vy = 0;

Во время drag:

vx = event.dx;
vy = event.dy;

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

function animate() {

    x += vx;
    y += vy;

    vx *= 0.95;
    vy *= 0.95;

    render();

    if (Math.abs(vx) > 0.1) {
        requestAnimationFrame(animate);
    }
}

Drag в адаптивных интерфейсах

На мобильных устройствах размеры SVG часто меняются.

Проблема:

event.x
event.y

зависят от системы координат контейнера.

При использовании viewBox необходимо учитывать масштабирование.

Пример:

const point = d3.pointer(event, svg.node());

d3.pointer() корректно преобразует координаты относительно контейнера.


Drag и accessibility

Сенсорные интерфейсы требуют поддержки доступности.

Рекомендуется:

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

Минимальный рекомендуемый размер touch-области:

44x44 px

Частые проблемы на мобильных устройствах

Элемент не двигается

Причины:

  • отсутствует touch-action: none;
  • drag навешан не на тот элемент;
  • событие перекрывается другим DOM-слоем.

Страница прокручивается вместо drag

Решение:

touch-action: none;
overflow: hidden;

Drag работает рывками

Причины:

  • тяжёлые вычисления;
  • слишком много DOM-обновлений;
  • сложные SVG-фильтры;
  • постоянный reflow.

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

Причины:

  • CSS transform контейнера;
  • масштабирование;
  • неправильная система координат.

Решение:

d3.pointer(event);

Архитектура touch-friendly drag

Стабильный drag-интерфейс обычно включает:

  1. touch-action;
  2. Pointer Events;
  3. requestAnimationFrame;
  4. ограничение координат;
  5. увеличенную touch-область;
  6. оптимизированный рендер;
  7. корректную работу с zoom;
  8. адаптацию под разные DPI и размеры экранов.

Такой подход обеспечивает:

  • плавное перемещение;
  • отсутствие конфликтов со scroll;
  • корректную работу на Android и iOS;
  • предсказуемое мультитач-поведение;
  • высокую производительность даже на слабых мобильных устройствах.