Ограничение перемещения в пределах контейнера

Визуальные интерфейсы на базе D3.js часто используют перетаскивание объектов: узлов графа, карточек, точек на графике, элементов диаграмм, интерактивных областей и ползунков. Без ограничений пользователь может переместить объект за пределы видимой области SVG, что приводит к нескольким проблемам:

  • потеря элемента из области просмотра;
  • нарушение структуры визуализации;
  • некорректное взаимодействие с другими объектами;
  • ошибки при вычислении координат;
  • ухудшение UX.

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


Механизм перетаскивания в D3.js

Для реализации drag-and-drop используется модуль d3.drag().

Базовая схема:

const drag = d3.drag()
    .on("drag", dragged);

d3.select("circle")
    .call(drag);

Во время события drag доступны координаты:

event.x
event.y

Эти значения отражают новую позицию курсора относительно SVG-контейнера.


Простое перемещение элемента

Пример свободного перемещения круга:

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

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

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

В этом варианте элемент можно увести за пределы SVG.


Основная идея ограничения координат

Ограничение строится на проверке диапазона координат.

Используются:

  • минимальная граница;
  • максимальная граница;
  • размеры элемента;
  • размеры контейнера.

Чаще всего применяется функция Math.max() и Math.min().


Ограничение внутри SVG

Пример для круга

const width = 800;
const height = 500;
const radius = 30;

const svg = d3.select("svg")
    .attr("width", width)
    .attr("height", height);

const circle = svg.append("circle")
    .attr("cx", 100)
    .attr("cy", 100)
    .attr("r", radius)
    .attr("fill", "orange");

circle.call(
    d3.drag()
        .on("drag", function(event) {

            const x = Math.max(
                radius,
                Math.min(width - radius, event.x)
            );

            const y = Math.max(
                radius,
                Math.min(height - radius, event.y)
            );

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

Как работает ограничение

Координата x вычисляется так:

Math.max(radius, Math.min(width - radius, event.x))

Алгоритм:

  1. Math.min() ограничивает правую границу.
  2. Math.max() ограничивает левую границу.

Если курсор выходит за пределы SVG:

  • координата фиксируется;
  • элемент остается внутри контейнера.

Учет размеров элемента

Без учета радиуса часть круга выйдет за пределы контейнера.

Неправильно:

const x = Math.max(0, Math.min(width, event.x));

Правильно:

const x = Math.max(radius, Math.min(width - radius, event.x));

То же относится:

  • к прямоугольникам;
  • изображениям;
  • группам (g);
  • пользовательским компонентам.

Ограничение для прямоугольника

Особенности координат

У rect координаты относятся к левому верхнему углу.

Пример:

const rectWidth = 120;
const rectHeight = 80;

const rect = svg.append("rect")
    .attr("x", 50)
    .attr("y", 50)
    .attr("width", rectWidth)
    .attr("height", rectHeight)
    .attr("fill", "tomato");

rect.call(
    d3.drag()
        .on("drag", function(event) {

            const x = Math.max(
                0,
                Math.min(width - rectWidth, event.x)
            );

            const y = Math.max(
                0,
                Math.min(height - rectHeight, event.y)
            );

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

Ограничение для группы элементов

Обычно сложные объекты объединяются в <g>.

Пример структуры:

<g class="node">
    <circle></circle>
    <text></text>
</g>

Перемещение выполняется через transform.


Ограничение группы через translate

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

group.append("circle")
    .attr("r", 40)
    .attr("fill", "purple");

group.call(
    d3.drag()
        .on("drag", function(event) {

            const x = Math.max(
                40,
                Math.min(width - 40, event.x)
            );

            const y = Math.max(
                40,
                Math.min(height - 40, event.y)
            );

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

Ограничение с использованием getBoundingClientRect

Иногда размеры SVG заранее неизвестны.

В этом случае контейнер вычисляется динамически.

const bounds = svg.node().getBoundingClientRect();

Пример:

const bounds = svg.node().getBoundingClientRect();

const maxX = bounds.width - radius;
const maxY = bounds.height - radius;

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

  • при адаптивной верстке;
  • при изменении размеров окна;
  • при использовании flex/grid layout.

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

SVG часто масштабируется через viewBox.

<svg viewBox="0 0 800 500"></svg>

В этом случае координаты работают в системе SVG, а не в CSS-пикселях.

Важно учитывать:

  • event.x и event.y относятся к внутренним координатам SVG;
  • ограничения должны вычисляться в тех же единицах.

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

При использовании d3.zoom() возникает дополнительная сложность: координаты изменяются из-за трансформации.

Типичная схема:

svg.call(
    d3.zoom()
        .on("zoom", zoomed)
);
function zoomed(event) {
    container.attr("transform", event.transform);
}

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


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

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

Доступны:

transform.x
transform.y
transform.k

Где:

  • x, y — смещение;
  • k — масштаб.

Коррекция координат при zoom

Пример:

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

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

    const x = (event.x - transform.x) / transform.k;
    const y = (event.y - transform.y) / transform.k;

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

Без этой коррекции объект будет двигаться некорректно при увеличении масштаба.


Ограничение по одной оси

Иногда разрешено движение только по горизонтали или вертикали.

Только горизонтальное движение

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

    const x = Math.max(
        radius,
        Math.min(width - radius, event.x)
    );

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

Только вертикальное движение

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

    const y = Math.max(
        radius,
        Math.min(height - radius, event.y)
    );

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

Создание универсальной функции ограничения

Повторение одинаковой логики приводит к дублированию кода.

Удобнее создать отдельную функцию.

function clamp(value, min, max) {
    return Math.max(min, Math.min(max, value));
}

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

const x = clamp(event.x, radius, width - radius);
const y = clamp(event.y, radius, height - radius);

Ограничение сложных фигур

Для сложных SVG-объектов нужно учитывать реальные размеры.

Например:

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

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

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

element.getBBox()

Он возвращает:

{
    x,
    y,
    width,
    height
}

Пример:

const box = this.getBBox();

Ограничение с учетом getBBox

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

    const box = this.getBBox();

    const x = Math.max(
        0,
        Math.min(width - box.width, event.x)
    );

    const y = Math.max(
        0,
        Math.min(height - box.height, event.y)
    );

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

Ограничение внутри произвольной области

Границы необязательно совпадают с SVG.

Например:

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

Ограничение внутри chart area

const margin = {
    top: 20,
    right: 20,
    bottom: 20,
    left: 20
};

const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;

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

const x = clamp(
    event.x,
    margin.left,
    margin.left + innerWidth
);

const y = clamp(
    event.y,
    margin.top,
    margin.top + innerHeight
);

Работа с force simulation

В d3-force узлы графа часто перетаскиваются вручную.

Стандартный обработчик:

function dragged(event, d) {
    d.fx = event.x;
    d.fy = event.y;
}

Без ограничений узлы могут покинуть область симуляции.


Ограничение force-узлов

function dragged(event, d) {

    d.fx = Math.max(
        radius,
        Math.min(width - radius, event.x)
    );

    d.fy = Math.max(
        radius,
        Math.min(height - radius, event.y)
    );
}

Ограничение через forceX и forceY

Дополнительный вариант — использование сил.

simulation
    .force("x", d3.forceX(width / 2))
    .force("y", d3.forceY(height / 2));

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


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

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

<clipPath id="clip">
    <rect width="800" height="500"></rect>
</clipPath>
container.attr("clip-path", "url(#clip)");

Важно понимать:

  • clipPath скрывает содержимое;
  • физически перемещение не ограничивается.

Ограничение через drag.subject

drag.subject() позволяет задавать объект перетаскивания.

Пример:

const drag = d3.drag()
    .subject(function(event, d) {
        return d;
    });

Это полезно при сложных структурах данных и пользовательских координатах.


Плавное ограничение

Жесткая остановка иногда выглядит резко.

Можно реализовать мягкое замедление возле границы.

Пример:

function softClamp(value, min, max, factor = 0.2) {

    if (value < min) {
        return min + (value - min) * factor;
    }

    if (value > max) {
        return max + (value - max) * factor;
    }

    return value;
}

Инерционное перемещение

Некоторые интерфейсы используют инерцию после отпускания мыши.

В этом случае ограничения проверяются:

  • во время drag;
  • во время анимации.

Обычно применяется:

requestAnimationFrame()

Ограничение для touch-событий

d3.drag() автоматически поддерживает touch-интерфейсы.

Однако на мобильных устройствах важны:

  • размеры активной зоны;
  • скорость обновления;
  • предотвращение выхода элемента за экран.

Оптимизация производительности

При большом количестве объектов drag может вызывать просадки FPS.

Рекомендации:

Минимизировать DOM-операции

Плохо:

d3.select(this)
    .style(...)
    .attr(...)
    .classed(...);

Лучше:

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

Не вычислять размеры на каждом кадре

Плохо:

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

    const bounds = svg.node().getBoundingClientRect();

});

Лучше:

const bounds = svg.node().getBoundingClientRect();

Использовать transform вместо x/y

Для сложных объектов быстрее:

.attr("transform", ...)

чем постоянное изменение множества атрибутов.


Частые ошибки

Игнорирование размеров объекта

Приводит к частичному выходу за контейнер.


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

Особенно при:

  • zoom;
  • viewBox;
  • transform;
  • nested SVG.

Использование clientX/clientY

В D3 предпочтительно использовать:

event.x
event.y

Конфликт drag и zoom

При неправильной организации событий:

  • zoom блокирует drag;
  • drag ломает pan;
  • возникают скачки координат.

Практический пример: ограниченная панель

const width = 900;
const height = 500;

const panelWidth = 180;
const panelHeight = 100;

const svg = d3.select("svg")
    .attr("width", width)
    .attr("height", height);

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

panel.append("rect")
    .attr("width", panelWidth)
    .attr("height", panelHeight)
    .attr("rx", 10)
    .attr("fill", "#4e79a7");

panel.append("text")
    .attr("x", 20)
    .attr("y", 50)
    .text("Drag Panel")
    .attr("fill", "white");

panel.call(
    d3.drag()
        .on("drag", function(event) {

            const x = Math.max(
                0,
                Math.min(width - panelWidth, event.x)
            );

            const y = Math.max(
                0,
                Math.min(height - panelHeight, event.y)
            );

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

Архитектура ограничений в крупных проектах

В больших визуализациях ограничения обычно выносятся:

  • в отдельные utility-функции;
  • в drag-manager;
  • в классы поведения;
  • в middleware событий.

Пример архитектуры:

class DragBoundary {

    constructor(width, height) {
        this.width = width;
        this.height = height;
    }

    clampX(x, size) {
        return Math.max(
            size,
            Math.min(this.width - size, x)
        );
    }

    clampY(y, size) {
        return Math.max(
            size,
            Math.min(this.height - size, y)
        );
    }
}

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

  • в редакторах диаграмм;
  • в конструкторах интерфейсов;
  • в графовых редакторах;
  • в системах визуальной аналитики.