Сохранение позиции после drag

Поведение перетаскивания в D3.js реализуется через модуль d3-drag. Во время перемещения элемента библиотека непрерывно изменяет его координаты, используя события:

  • start
  • drag
  • end

Базовая схема выглядит следующим образом:

const drag = d3.drag()
    .on("start", dragStarted)
    .on("drag", dragged)
    .on("end", dragEnded);

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

Во время обработки события drag обычно обновляются координаты SVG-элемента:

function dragged(event, d) {
    d3.select(this)
        .attr("cx", event.x)
        .attr("cy", event.y);
}

Однако без дополнительной логики после повторного рендера, обновления данных или перерисовки графа элемент может вернуться в исходное положение. Для предотвращения такого поведения требуется сохранять координаты внутри данных.


Почему позиция теряется после drag

Частая ошибка — обновление только DOM-атрибутов без изменения объекта данных.

Пример неправильного подхода:

function dragged(event) {
    d3.select(this)
        .attr("cx", event.x)
        .attr("cy", event.y);
}

SVG визуально изменяется, но источник данных остаётся прежним. При следующем вызове:

selection.attr("cx", d => d.x);

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

Причина заключается в архитектуре D3.js:

  • DOM отображает текущее состояние;
  • данные являются источником истины;
  • повторный рендер всегда использует данные.

Сохранение координат в объекте данных

Правильный подход — обновлять данные одновременно с DOM.

Исходный массив

const nodes = [
    { id: 1, x: 100, y: 120 },
    { id: 2, x: 300, y: 200 }
];

Создание элементов

svg.selectAll("circle")
    .data(nodes)
    .enter()
    .append("circle")
    .attr("cx", d => d.x)
    .attr("cy", d => d.y)
    .attr("r", 20);

Drag с сохранением позиции

const drag = d3.drag()
    .on("drag", function(event, d) {

        d.x = event.x;
        d.y = event.y;

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

Теперь координаты сохраняются непосредственно в объекте:

{
    id: 1,
    x: 240,
    y: 180
}

При любом повторном рендере элемент останется на новом месте.


Повторный рендер после drag

После изменения данных интерфейс можно безопасно перерисовывать.

function render() {

    svg.selectAll("circle")
        .data(nodes)
        .join("circle")
        .attr("cx", d => d.x)
        .attr("cy", d => d.y)
        .attr("r", 20);
}

Если координаты были сохранены:

d.x = event.x;
d.y = event.y;

то повторный вызов:

render();

не приведёт к сбросу позиции.


Использование transform вместо координат

Во многих визуализациях перемещение реализуется через transform.

Пример

.attr("transform", d => `translate(${d.x}, ${d.y})`)

Во время drag:

function dragged(event, d) {

    d.x = event.x;
    d.y = event.y;

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

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

  • групп <g>;
  • сложных узлов;
  • комбинированных SVG-элементов;
  • force-графов.

Drag для группы элементов

Обычно интерактивный узел состоит не из одного SVG-элемента, а из нескольких:

<g>
    <circle></circle>
    <text></text>
</g>

В этом случае drag применяется к контейнеру <g>.

Пример

const node = svg.selectAll(".node")
    .data(nodes)
    .enter()
    .append("g")
    .attr("class", "node")
    .attr("transform", d => `translate(${d.x}, ${d.y})`)
    .call(
        d3.drag()
            .on("drag", dragged)
    );

Функция перемещения:

function dragged(event, d) {

    d.x = event.x;
    d.y = event.y;

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

Положение всей группы сохраняется в объекте данных.


Разница между event.x и event.dx

D3 предоставляет несколько вариантов вычисления позиции.

Абсолютные координаты

event.x
event.y

Это текущая позиция курсора.

Смещение

event.dx
event.dy

Это изменение относительно предыдущего события.


Сохранение позиции через смещение

Иногда удобнее накапливать координаты вручную.

function dragged(event, d) {

    d.x += event.dx;
    d.y += event.dy;

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

Подход полезен при:

  • сложных трансформациях;
  • масштабировании;
  • вложенных контейнерах;
  • нестандартных системах координат.

Сохранение позиции в force simulation

В d3-force координаты рассчитываются физическим движком. Если drag не фиксирует позицию, узел может вернуться обратно.

Пример

const simulation = d3.forceSimulation(nodes)
    .force("charge", d3.forceManyBody())
    .force("center", d3.forceCenter(400, 300));

Фиксация позиции через fx и fy

Для force simulation используются специальные свойства:

d.fx
d.fy

Drag с фиксацией

const drag = d3.drag()
    .on("start", function(event, d) {

        d.fx = d.x;
        d.fy = d.y;
    })
    .on("drag", function(event, d) {

        d.fx = event.x;
        d.fy = event.y;
    })
    .on("end", function(event, d) {

        d.fx = event.x;
        d.fy = event.y;
    });

Теперь узел сохраняет новую позицию даже после обновления simulation.


Освобождение узла после drag

Если требуется временное перетаскивание без постоянной фиксации:

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

    d.fx = null;
    d.fy = null;
});

После отпускания мыши физическая симуляция снова начинает двигать элемент.


Сохранение позиции между загрузками страницы

Для долговременного хранения координат используются:

  • localStorage;
  • база данных;
  • серверное API;
  • JSON-файл.

Сохранение в localStorage

Запись

localStorage.setItem(
    "graph-nodes",
    JSON.stringify(nodes)
);

Восстановление

const saved = localStorage.getItem("graph-nodes");

if (saved) {
    nodes = JSON.parse(saved);
}

Автосохранение после drag

function dragEnded(event, d) {

    localStorage.setItem(
        "nodes",
        JSON.stringify(nodes)
    );
}

Теперь после обновления страницы элементы сохранят свои координаты.


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

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

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

function dragged(event, d) {

    d.x = Math.max(0, Math.min(width, event.x));
    d.y = Math.max(0, Math.min(height, event.y));

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

Учет радиуса элемента

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

const radius = 20;

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

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

Сохранение позиции при zoom

При использовании d3.zoom() координаты курсора могут отличаться от координат SVG.

Типичная проблема

После масштабирования элемент прыгает в неправильное место.

Причина — drag работает в экранных координатах, а SVG находится под трансформацией.


Использование transform.invert

function dragged(event, d) {

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

    const point = transform.invert([
        event.x,
        event.y
    ]);

    d.x = point[0];
    d.y = point[1];

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

Сохранение позиции при data join

При обновлении данных важно правильно задавать ключи.

Неправильный вариант

.data(nodes)

D3 может пересоздать элементы.

Правильный вариант

.data(nodes, d => d.id)

Теперь библиотека понимает, какой объект соответствует какому DOM-элементу.


Полный пример сохранения позиции

const width = 800;
const height = 500;

const nodes = [
    { id: 1, x: 100, y: 100 },
    { id: 2, x: 300, y: 200 }
];

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

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

function render() {

    svg.selectAll("circle")
        .data(nodes, d => d.id)
        .join("circle")
        .attr("r", 25)
        .attr("cx", d => d.x)
        .attr("cy", d => d.y)
        .call(drag);
}

function dragged(event, d) {

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

    d.y = Math.max(25,
        Math.min(height - 25, event.y));

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

function savePositions() {

    localStorage.setItem(
        "saved-nodes",
        JSON.stringify(nodes)
    );
}

render();

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

Обновление только DOM

.attr("cx", event.x)

Без:

d.x = event.x

позиция теряется.


Пересоздание массива данных

nodes = newArray;

Старые координаты исчезают.


Отсутствие ключей в data join

.data(nodes)

может приводить к уничтожению DOM-элементов.


Использование event.x внутри zoom

Без transform.invert() координаты становятся некорректными.


Потеря контекста this

Стрелочные функции меняют контекст.

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

.on("drag", (event, d) => {
    d3.select(this);
})

Правильно:

.on("drag", function(event, d) {
    d3.select(this);
})

Архитектура хранения координат

Наиболее устойчивый подход строится по следующей схеме:

  1. Координаты хранятся в данных.
  2. DOM всегда рендерится из данных.
  3. Drag изменяет данные.
  4. Повторный рендер использует обновлённые координаты.
  5. При необходимости данные сериализуются во внешнее хранилище.

Такая модель обеспечивает:

  • устойчивость к перерисовкам;
  • корректную работу с zoom;
  • совместимость с force simulation;
  • сохранение состояния интерфейса;
  • масштабируемость сложных визуализаций.