Перетаскивание с привязкой к сетке (snap to grid)

Перетаскивание с привязкой к сетке (snap to grid) — техника, при которой координаты перемещаемого объекта автоматически округляются до ближайших ячеек условной сетки. Такой подход широко используется:

  • в графических редакторах;
  • конструкторах диаграмм;
  • системах визуального моделирования;
  • CAD-интерфейсах;
  • редакторах схем;
  • игровых уровнях;
  • drag-and-drop интерфейсах.

В библиотеке D3.js реализация snap to grid строится вокруг поведения d3.drag(), обработки координат курсора и математического округления.


Принцип работы привязки к сетке

Механизм состоит из трёх этапов:

  1. Получение координат курсора во время перетаскивания.
  2. Вычисление ближайшей точки сетки.
  3. Обновление позиции элемента.

Схема вычисления выглядит так:

snappedX = Math.round(x / gridSize) * gridSize
snappedY = Math.round(y / gridSize) * gridSize

Если размер клетки сетки равен 20, то координата 53 будет преобразована в 60, а 47 — в 40.


Базовая настройка SVG-сцены

Создание рабочей области:

const width = 800
const height = 500

const svg = d3
    .select("body")
    .append("svg")
    .attr("width", width)
    .attr("height", height)
    .style("border", "1px solid #999")

Добавление объекта:

const rect = svg
    .append("rect")
    .attr("x", 100)
    .attr("y", 100)
    .attr("width", 80)
    .attr("height", 80)
    .attr("fill", "steelblue")

Создание сетки

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

Вертикальные линии

const gridSize = 20

for (let x = 0; x <= width; x += gridSize) {
    svg.append("line")
        .attr("x1", x)
        .attr("y1", 0)
        .attr("x2", x)
        .attr("y2", height)
        .attr("stroke", "#eee")
}

Горизонтальные линии

for (let y = 0; y <= height; y += gridSize) {
    svg.append("line")
        .attr("x1", 0)
        .attr("y1", y)
        .attr("x2", width)
        .attr("y2", y)
        .attr("stroke", "#eee")
}

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

В D3 перетаскивание реализуется через d3.drag().

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

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

rect.call(drag)

Получение координат курсора

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

function dragged(event) {
    console.log(event.x, event.y)
}
  • event.x — текущая координата X;
  • event.y — текущая координата Y.

Реализация snap to grid

Полная реализация:

const gridSize = 20

function dragged(event) {

    const snappedX =
        Math.round(event.x / gridSize) * gridSize

    const snappedY =
        Math.round(event.y / gridSize) * gridSize

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

Подключение:

rect.call(
    d3.drag().on("drag", dragged)
)

Полный пример

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>D3 Snap To Grid</title>

<script src="https://d3js.org/d3.v7.min.js"></script>

<style>
body {
    margin: 0;
    overflow: hidden;
}
</style>

</head>
<body>

<script>

const width = 800
const height = 500
const gridSize = 25

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

for (let x = 0; x <= width; x += gridSize) {

    svg.append("line")
        .attr("x1", x)
        .attr("y1", 0)
        .attr("x2", x)
        .attr("y2", height)
        .attr("stroke", "#eee")
}

for (let y = 0; y <= height; y += gridSize) {

    svg.append("line")
        .attr("x1", 0)
        .attr("y1", y)
        .attr("x2", width)
        .attr("y2", y)
        .attr("stroke", "#eee")
}

const rect = svg
    .append("rect")
    .attr("x", 100)
    .attr("y", 100)
    .attr("width", 100)
    .attr("height", 100)
    .attr("fill", "tomato")

rect.call(

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

            const x =
                Math.round(event.x / gridSize) * gridSize

            const y =
                Math.round(event.y / gridSize) * gridSize

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

</script>

</body>
</html>

Использование Math.floor() и Math.ceil()

Тип округления влияет на поведение привязки.

Округление к ближайшей ячейке

Math.round(value / grid) * grid

Округление вниз

Math.floor(value / grid) * grid

Округление вверх

Math.ceil(value / grid) * grid

Привязка центра объекта

Частая ошибка — привязка левого верхнего угла вместо центра элемента.

Неправильный подход

.attr("x", snappedX)
.attr("y", snappedY)

Корректная центровка

const size = 80

.attr("x", snappedX - size / 2)
.attr("y", snappedY - size / 2)

Работа с кругами

У круга используются координаты cx и cy.

const circle = svg
    .append("circle")
    .attr("cx", 200)
    .attr("cy", 200)
    .attr("r", 30)

Перетаскивание:

circle.call(

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

            const x =
                Math.round(event.x / gridSize) * gridSize

            const y =
                Math.round(event.y / gridSize) * gridSize

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

Использование трансформации translate

Во многих проектах элементы перемещаются через transform.

Исходный элемент

const node = svg.append("g")

Перемещение

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

Drag с привязкой

node.call(

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

            const x =
                Math.round(event.x / gridSize) * gridSize

            const y =
                Math.round(event.y / gridSize) * gridSize

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

Хранение координат в данных

В D3 координаты часто находятся внутри объекта данных.

Данные

const data = {
    x: 100,
    y: 100
}

Привязка

const rect = svg
    .append("rect")
    .datum(data)

Обновление данных

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

    d.x =
        Math.round(event.x / gridSize) * gridSize

    d.y =
        Math.round(event.y / gridSize) * gridSize

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

Ограничение области перемещения

Без ограничений объект может выходить за пределы SVG.

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

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

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

Плавное перемещение к ячейке

Иногда привязка выполняется после отпускания мыши.

Перетаскивание без snap

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

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

Привязка при завершении

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

    const x =
        Math.round(event.x / gridSize) * gridSize

    const y =
        Math.round(event.y / gridSize) * gridSize

    d3.select(this)
        .transition()
        .duration(150)
        .attr("x", x)
        .attr("y", y)
})

Магнитная привязка

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

Расстояние до ближайшей ячейки

const nearest =
    Math.round(x / gridSize) * gridSize

const distance =
    Math.abs(x - nearest)

Проверка порога

if (distance < 5) {
    x = nearest
}

Отображение активной ячейки

Полезная техника для визуального редактора — подсветка текущей клетки.

const helper = svg
    .append("rect")
    .attr("fill", "rgba(0,0,255,0.1)")

Обновление:

helper
    .attr("x", snappedX)
    .attr("y", snappedY)
    .attr("width", gridSize)
    .attr("height", gridSize)

Производительность больших сеток

Большое количество SVG-линий ухудшает производительность.

Проблемный вариант

5000+ SVG line элементов

Более эффективные подходы

  • использование pattern;
  • отрисовка через canvas;
  • комбинирование линий в path;
  • отображение только видимой области.

Сетка через SVG pattern

Оптимальный способ отображения сетки.

const defs = svg.append("defs")

const pattern = defs
    .append("pattern")
    .attr("id", "grid")
    .attr("width", gridSize)
    .attr("height", gridSize)
    .attr("patternUnits", "userSpaceOnUse")

pattern.append("path")
    .attr("d", `
        M ${gridSize} 0
        L 0 0
        L 0 ${gridSize}
    `)
    .attr("fill", "none")
    .attr("stroke", "#ddd")

svg.append("rect")
    .attr("width", width)
    .attr("height", height)
    .attr("fill", "url(#grid)")

Snap по одной оси

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

Только по горизонтали

.attr(
    "x",
    Math.round(event.x / gridSize) * gridSize
)
.attr("y", event.y)

Только по вертикали

.attr("x", event.x)
.attr(
    "y",
    Math.round(event.y / gridSize) * gridSize
)

Привязка к нестандартной сетке

Сетка может быть:

  • изометрической;
  • шестиугольной;
  • полярной;
  • радиальной.

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


Привязка к пользовательским точкам

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

const points = [
    [100, 100],
    [200, 150],
    [400, 300]
]

Поиск ближайшей точки:

function findNearest(x, y) {

    let minDistance = Infinity
    let nearest = null

    for (const point of points) {

        const dx = point[0] - x
        const dy = point[1] - y

        const dist =
            Math.sqrt(dx * dx + dy * dy)

        if (dist < minDistance) {
            minDistance = dist
            nearest = point
        }
    }

    return nearest
}

Совместимость с zoom

При использовании d3.zoom() координаты мыши находятся в трансформированном пространстве.

Ошибочная реализация

event.x
event.y

Корректный вариант

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

const x = transform.invertX(event.x)
const y = transform.invertY(event.y)

После вычисления snap координаты снова преобразуются.


Snap в force-directed графах

В силовых графах привязка используется для:

  • выравнивания узлов;
  • построения схем;
  • фиксации структуры.

Пример:

simulation.on("tick", () => {

    node
        .attr("cx", d =>
            Math.round(d.x / gridSize) * gridSize
        )
        .attr("cy", d =>
            Math.round(d.y / gridSize) * gridSize
        )
})

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

Рывки при начале drag

Причина:

event.x

не учитывает смещение курсора внутри элемента.

Решение — хранить offset.


Неверная работа после zoom

Причина — отсутствие invertX() и invertY().


Объект выходит за границы

Причина — отсутствие ограничения координат.


Сетка тормозит интерфейс

Причина — слишком много SVG-элементов.

Решение:

  • pattern;
  • canvas;
  • виртуализация.

Архитектура drag-системы

Крупные редакторы обычно разделяют систему на слои:

Слой Назначение
Grid System вычисление координат
Drag Controller обработка перетаскивания
Snap Engine логика привязки
Renderer обновление SVG
State Manager хранение данных

Такой подход упрощает:

  • поддержку;
  • тестирование;
  • масштабирование;
  • внедрение новых типов сеток.

Практический паттерн Snap Engine

class SnapEngine {

    constructor(size) {
        this.size = size
    }

    snap(value) {
        return Math.round(value / this.size)
            * this.size
    }

    snapPoint(x, y) {

        return {
            x: this.snap(x),
            y: this.snap(y)
        }
    }
}

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

const engine = new SnapEngine(20)

const point =
    engine.snapPoint(event.x, event.y)

Комбинация drag, zoom и snap

Наиболее сложный сценарий включает одновременно:

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

В таких системах особенно важно:

  • разделение координат мира и экрана;
  • единый менеджер трансформаций;
  • отказ от прямой модификации DOM без состояния;
  • централизованная математика snap-вычислений.