Визуальные интерфейсы на базе D3.js часто используют перетаскивание объектов: узлов графа, карточек, точек на графике, элементов диаграмм, интерактивных областей и ползунков. Без ограничений пользователь может переместить объект за пределы видимой области SVG, что приводит к нескольким проблемам:
Ограничение перемещения позволяет удерживать объект внутри контейнера, задавая допустимые границы координат.
Для реализации 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().
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))
Алгоритм:
Math.min() ограничивает правую границу.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.
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})`);
})
);
Иногда размеры SVG заранее неизвестны.
В этом случае контейнер вычисляется динамически.
const bounds = svg.node().getBoundingClientRect();
Пример:
const bounds = svg.node().getBoundingClientRect();
const maxX = bounds.width - radius;
const maxY = bounds.height - radius;
Такой подход полезен:
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);
}
Если объект перемещается внутри масштабируемой группы, необходимо учитывать текущий масштаб.
const transform = d3.zoomTransform(svg.node());
Доступны:
transform.x
transform.y
transform.k
Где:
x, y — смещение;k — масштаб.Пример:
.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;SVG предоставляет метод:
element.getBBox()
Он возвращает:
{
x,
y,
width,
height
}
Пример:
const box = this.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.
Например:
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
);
В d3-force узлы графа часто перетаскиваются вручную.
Стандартный обработчик:
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
Без ограничений узлы могут покинуть область симуляции.
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)
);
}
Дополнительный вариант — использование сил.
simulation
.force("x", d3.forceX(width / 2))
.force("y", d3.forceY(height / 2));
Однако это не жесткое ограничение, а только стремление вернуть узлы в область.
Иногда ограничение движения комбинируется с визуальным скрытием.
<clipPath id="clip">
<rect width="800" height="500"></rect>
</clipPath>
container.attr("clip-path", "url(#clip)");
Важно понимать:
clipPath скрывает содержимое;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;
}
Некоторые интерфейсы используют инерцию после отпускания мыши.
В этом случае ограничения проверяются:
Обычно применяется:
requestAnimationFrame()
d3.drag() автоматически поддерживает
touch-интерфейсы.
Однако на мобильных устройствах важны:
При большом количестве объектов drag может вызывать просадки FPS.
Рекомендации:
Плохо:
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();
Для сложных объектов быстрее:
.attr("transform", ...)
чем постоянное изменение множества атрибутов.
Приводит к частичному выходу за контейнер.
Особенно при:
В D3 предпочтительно использовать:
event.x
event.y
При неправильной организации событий:
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})`);
})
);
В больших визуализациях ограничения обычно выносятся:
Пример архитектуры:
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)
);
}
}
Такой подход особенно полезен: