Перетаскивание с привязкой к сетке (snap to grid) — техника, при которой координаты перемещаемого объекта автоматически округляются до ближайших ячеек условной сетки. Такой подход широко используется:
В библиотеке D3.js реализация snap to grid строится вокруг поведения
d3.drag(), обработки координат курсора и математического
округления.
Механизм состоит из трёх этапов:
Схема вычисления выглядит так:
snappedX = Math.round(x / gridSize) * gridSize
snappedY = Math.round(y / gridSize) * gridSize
Если размер клетки сетки равен 20, то координата
53 будет преобразована в 60, а 47
— в 40.
Создание рабочей области:
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")
}
В 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.Полная реализация:
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})`
)
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)
)
Иногда привязка выполняется после отпускания мыши.
.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;Оптимальный способ отображения сетки.
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)")
Иногда требуется фиксировать только 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
}
При использовании 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 координаты снова преобразуются.
В силовых графах привязка используется для:
Пример:
simulation.on("tick", () => {
node
.attr("cx", d =>
Math.round(d.x / gridSize) * gridSize
)
.attr("cy", d =>
Math.round(d.y / gridSize) * gridSize
)
})
Причина:
event.x
не учитывает смещение курсора внутри элемента.
Решение — хранить offset.
Причина — отсутствие invertX() и
invertY().
Причина — отсутствие ограничения координат.
Причина — слишком много SVG-элементов.
Решение:
pattern;canvas;Крупные редакторы обычно разделяют систему на слои:
| Слой | Назначение |
|---|---|
| Grid System | вычисление координат |
| Drag Controller | обработка перетаскивания |
| Snap Engine | логика привязки |
| Renderer | обновление SVG |
| State Manager | хранение данных |
Такой подход упрощает:
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)
Наиболее сложный сценарий включает одновременно:
В таких системах особенно важно: