Одновременная работа drag и zoom — одна из
наиболее важных техник при создании интерактивных визуализаций. Такая
комбинация используется в:
Основная сложность заключается в том, что оба поведения работают с событиями мыши и могут конфликтовать друг с другом. Неправильная организация обработчиков приводит к рывкам, ложным перемещениям, потере координат и некорректному масштабированию.
Обычно используются два уровня:
zoom.drag.Типичная структура SVG:
<svg width="800" height="600">
<g class="viewport">
<circle />
<circle />
<circle />
</g>
</svg>
svg принимает zoom-события.g.viewport получает трансформации.const svg = d3.select("svg");
const viewport = svg.append("g")
.attr("class", "viewport");
const zoom = d3.zoom()
.scaleExtent([0.5, 5])
.on("zoom", (event) => {
viewport.attr("transform", event.transform);
});
svg.call(zoom);
const circles = viewport.selectAll("circle")
.data([1, 2, 3])
.enter()
.append("circle")
.attr("cx", (d, i) => i * 120 + 100)
.attr("cy", 200)
.attr("r", 40);
const drag = d3.drag()
.on("drag", function(event) {
d3.select(this)
.attr("cx", event.x)
.attr("cy", event.y);
});
circles.call(drag);
После этого:
Без дополнительных настроек могут возникнуть проблемы:
Причина — обе системы используют pointer-события.
Наиболее распространённая схема:
| Объект | Поведение |
|---|---|
| SVG | zoom |
| Элементы | drag |
Пример:
svg.call(zoom);
circles.call(drag);
Такой подход уже предотвращает большую часть конфликтов.
Иногда zoom всё равно реагирует на drag.
Используется остановка propagation:
const drag = d3.drag()
.on("start", function(event) {
event.sourceEvent.stopPropagation();
})
.on("drag", function(event) {
d3.select(this)
.attr("cx", event.x)
.attr("cy", event.y);
});
Теперь drag не передаёт события zoom-поведению.
Во время масштабирования D3 формирует объект:
event.transform
Он содержит:
{
x,
y,
k
}
Где:
x — смещение по X;y — смещение по Y;k — коэффициент масштаба.viewport.attr("transform", event.transform);
D3 автоматически преобразует объект в строку:
translate(x, y) scale(k)
Наиболее распространённая ошибка — неправильные координаты при zoom.
Например:
.attr("cx", event.x)
.attr("cy", event.y)
После увеличения масштаба координаты становятся некорректными.
Причина — drag работает в экранных координатах, а SVG уже трансформирован.
Необходимо переводить координаты мыши в систему координат сцены.
const transform = d3.zoomTransform(svg.node());
const point = transform.invert([event.x, event.y]);
Теперь:
point[0]
point[1]
содержат реальные координаты сцены.
const drag = d3.drag()
.on("drag", function(event) {
const transform = d3.zoomTransform(svg.node());
const [x, y] = transform.invert([event.x, event.y]);
d3.select(this)
.attr("cx", x)
.attr("cy", y);
});
Без offset элемент может “прыгать” к курсору.
const drag = d3.drag()
.on("start", function(event, d) {
d.offsetX = d3.select(this).attr("cx") - event.x;
d.offsetY = d3.select(this).attr("cy") - event.y;
})
.on("drag", function(event, d) {
d3.select(this)
.attr("cx", event.x + d.offsetX)
.attr("cy", event.y + d.offsetY);
});
Полноценная версия:
const drag = d3.drag()
.on("start", function(event, d) {
const transform = d3.zoomTransform(svg.node());
const [x, y] = transform.invert([event.x, event.y]);
d.offsetX = +d3.select(this).attr("cx") - x;
d.offsetY = +d3.select(this).attr("cy") - y;
})
.on("drag", function(event, d) {
const transform = d3.zoomTransform(svg.node());
const [x, y] = transform.invert([event.x, event.y]);
d3.select(this)
.attr("cx", x + d.offsetX)
.attr("cy", y + d.offsetY);
});
Для предотвращения чрезмерного zoom:
const zoom = d3.zoom()
.scaleExtent([0.5, 8]);
Можно ограничить область панорамирования:
.zoom()
.translateExtent([
[0, 0],
[2000, 2000]
]);
.extent([
[0, 0],
[800, 600]
]);
Иногда масштабирование колесом мешает drag.
svg.call(
zoom.filter((event) => {
return event.type !== "wheel";
})
);
Например, zoom только с зажатым Ctrl:
const zoom = d3.zoom()
.filter((event) => {
return event.ctrlKey;
});
const drag = d3.drag()
.filter((event) => event.button === 0);
D3 автоматически масштабирует относительно позиции курсора.
Это одна из причин популярности d3.zoom() по сравнению с
ручной реализацией transform.
svg.call(
zoom.transform,
d3.zoomIdentity.translate(200, 100).scale(2)
);
svg.transition()
.duration(500)
.call(
zoom.transform,
d3.zoomIdentity
);
Один из важнейших сценариев — совмещение drag, zoom и force layout.
const simulation = d3.forceSimulation(nodes)
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(400, 300));
const drag = d3.drag()
.on("start", dragStarted)
.on("drag", dragged)
.on("end", dragEnded);
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;
}
svg.call(zoom);
node.call(drag);
Zoom применяется к контейнеру:
const graph = svg.append("g");
D3 работает не только с SVG.
context.translate(transform.x, transform.y);
context.scale(transform.k, transform.k);
zoom.on("zoom", (event) => {
transform = event.transform;
render();
});
Canvas не хранит DOM-элементы, поэтому необходимо:
function findNode(x, y) {
return nodes.find(node => {
const dx = x - node.x;
const dy = y - node.y;
return dx * dx + dy * dy < 400;
});
}
Современные версии D3 используют Pointer Events.
Это обеспечивает:
На мобильных устройствах d3.zoom() автоматически
поддерживает:
Дополнительная реализация обычно не требуется.
svg.call(zoom)
.on("dblclick.zoom", null);
svg.transition()
.duration(750)
.call(
zoom.scaleTo,
3
);
svg.transition()
.call(
zoom.translateTo,
500,
300
);
svg.transition()
.duration(800)
.call(zoom.translateTo, 400, 300)
.call(zoom.scaleTo, 2);
При большом количестве элементов drag + zoom может вызывать:
Плохо:
.attr("cx", x)
.attr("cy", y)
Лучше:
.attr("transform", `translate(${x}, ${y})`)
let frameRequested = false;
zoom.on("zoom", (event) => {
if (!frameRequested) {
requestAnimationFrame(() => {
viewport.attr("transform", event.transform);
frameRequested = false;
});
frameRequested = true;
}
});
SVG плохо масштабируется при десятках тысяч объектов.
В таких случаях используют:
Часто применяют:
| Слой | Назначение |
|---|---|
| Canvas | Рендер |
| SVG | Интерактивность |
| HTML | Tooltip/UI |
При работе drag + zoom существует три системы координат:
| Система | Описание |
|---|---|
| Screen | Координаты курсора |
| SVG | Координаты viewport |
| Data | Координаты данных |
Ошибки почти всегда связаны с неправильным преобразованием между ними.
d3.zoomIdentity
Начальное transform-состояние:
{
x: 0,
y: 0,
k: 1
}
Преобразование координат:
const screenX = transform.applyX(dataX);
const screenY = transform.applyY(dataY);
Обратное преобразование:
const dataX = transform.invertX(screenX);
const dataY = transform.invertY(screenY);
svg
└── viewport
├── links
├── nodes
└── labels
| Элемент | Поведение |
|---|---|
| svg | zoom |
| nodes | drag |
| viewport | transform |
const svg = d3.select("svg");
const viewport = svg.append("g");
const zoom = d3.zoom()
.scaleExtent([0.5, 4])
.on("zoom", (event) => {
viewport.attr("transform", event.transform);
});
svg.call(zoom);
const data = [
{ x: 100, y: 100 },
{ x: 300, y: 200 },
{ x: 500, y: 300 }
];
const drag = d3.drag()
.on("start", function(event) {
event.sourceEvent.stopPropagation();
})
.on("drag", function(event, d) {
const transform = d3.zoomTransform(svg.node());
const [x, y] = transform.invert([event.x, event.y]);
d.x = x;
d.y = y;
d3.select(this)
.attr("cx", d.x)
.attr("cy", d.y);
});
viewport.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("cx", d => d.x)
.attr("cy", d => d.y)
.attr("r", 30)
.call(drag);
Это приводит к конфликту с zoom.
Координаты ломаются после масштабирования.
Возникают скачки объектов.
Zoom должен быть привязан к корневому контейнеру.
Сильно ухудшает производительность.
transform.invert.stopPropagation.