Синхронизация зума между несколькими элементами

Масштабирование в D3 реализуется через модуль поведения zoom, который основан на перехвате событий pointer и трансформации координатного пространства. Основная идея заключается не в изменении самих данных, а в изменении матрицы преобразования transform, которая применяется к SVG-группе или canvas-контексту.

Ключевая сущность:

  • d3.zoom() — поведение масштабирования
  • d3.zoomTransform(node) — получение текущего трансформационного состояния
  • event.transform — объект текущего зума
  • transform.k, transform.x, transform.y — масштаб и смещение
const zoom = d3.zoom()
  .scaleExtent([1, 10])
  .on("zoom", zoomed);

svg.call(zoom);

function zoomed(event) {
  g.attr("transform", event.transform);
}

Здесь вся логика замыкается на одном DOM-элементе. Однако при появлении нескольких визуальных областей возникает задача синхронизации состояния между ними.


Проблема синхронизации нескольких zoom-контейнеров

При наличии нескольких графиков, каждый из которых поддерживает zoom, возникает расхождение состояний:

  • пользователь масштабирует первую диаграмму
  • вторая остаётся в исходном состоянии
  • визуальная связь между графиками теряется

Необходим механизм, при котором одно zoom-событие становится источником истины для всех связанных элементов.

Основная сложность:

  • предотвращение бесконечных циклов событий
  • согласование трансформаций
  • единое состояние масштаба

Единое состояние трансформации

Базовый подход заключается в выделении общего состояния:

let sharedTransform = d3.zoomIdentity;

Все графики используют это состояние как источник истины.


Привязка zoom к нескольким графикам

Создаётся единое поведение zoom:

const zoom = d3.zoom()
  .scaleExtent([1, 10])
  .on("zoom", handleZoom);

Каждый график получает один и тот же обработчик:

svgA.call(zoom);
svgB.call(zoom);

Обработчик с синхронизацией состояния

function handleZoom(event) {
  sharedTransform = event.transform;

  applyTransform(svgA, sharedTransform);
  applyTransform(svgB, sharedTransform);
}

Функция применения трансформации:

function applyTransform(svg, transform) {
  svg.select("g")
    .attr("transform", transform);
}

Такой подход уже синхронизирует визуальное поведение, но содержит скрытую проблему: повторный вызов zoom внутри update может вызвать цепочку событий.


Предотвращение рекурсивных событий

D3 генерирует события zoom при программном вызове call(zoom.transform, ...). Это приводит к циклическим обновлениям, если не учитывать источник события.

Решение — фильтрация sourceEvent:

function handleZoom(event) {
  if (event.sourceEvent) {
    sharedTransform = event.transform;

    svgA.call(zoom.transform, sharedTransform);
    svgB.call(zoom.transform, sharedTransform);
  }
}

Однако этот вариант всё ещё может вызвать повторные вызовы обработчика.

Более стабильный подход — разделение:

  • пользовательские события
  • программные обновления

Управляемая синхронизация через флаг блокировки

let isSyncing = false;

function handleZoom(event) {
  if (isSyncing) return;

  sharedTransform = event.transform;

  isSyncing = true;

  svgA.call(zoom.transform, sharedTransform);
  svgB.call(zoom.transform, sharedTransform);

  isSyncing = false;
}

Такой механизм гарантирует, что каскадные вызовы не запускают повторную синхронизацию.


Использование независимых zoom с общим state-слоем

Более масштабируемая архитектура предполагает, что каждый график имеет собственный zoom, но состояние централизовано.

const zoomA = d3.zoom().on("zoom", zoomedA);
const zoomB = d3.zoom().on("zoom", zoomedB);

Общий state:

let sharedTransform = d3.zoomIdentity;

Обработчики:

function zoomedA(event) {
  sync(event.transform, svgB, zoomB);
}

function zoomedB(event) {
  sync(event.transform, svgA, zoomA);
}

Функция синхронизации:

function sync(transform, targetSvg, targetZoom) {
  sharedTransform = transform;
  targetSvg.call(targetZoom.transform, transform);
}

Инверсия масштабирования и единая координатная система

При синхронизации важно учитывать, что zoom влияет не только на визуальное представление, но и на оси данных.

Для осей используется пересчёт шкал:

const newX = transform.rescaleX(xScale);
const newY = transform.rescaleY(yScale);

Применение к осям:

xAxis.call(d3.axisBottom(newX));
yAxis.call(d3.axisLeft(newY));

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


Синхронизация разных типов представлений

Часто графики имеют разную структуру:

  • scatter plot
  • line chart
  • heatmap

В таком случае zoom должен быть унифицирован на уровне координат, а не DOM.

Подход:

  • хранить sharedTransform
  • пересчитывать шкалы отдельно для каждого графика
  • применять transform только как функцию преобразования координат

Использование dispatch как центра событий

Для более сложных систем применяется event bus:

const dispatch = d3.dispatch("zoomchange");

Подписка:

dispatch.on("zoomchange", (transform) => {
  sharedTransform = transform;
  applyAll(transform);
});

График:

function zoomed(event) {
  dispatch.call("zoomchange", null, event.transform);
}

Этот подход устраняет прямые зависимости между компонентами.


Программное управление zoom-состоянием

Внешнее управление часто требуется для:

  • кнопок reset zoom
  • синхронизации с UI
  • анимации переходов
svg.call(zoom.transform, d3.zoomIdentity);

или возврат к состоянию:

svg.call(zoom.transform, sharedTransform);

Анимация:

svg.transition()
  .duration(750)
  .call(zoom.transform, sharedTransform);

Согласование zoom при инициализации

При создании нескольких графиков важно синхронизировать начальное состояние:

const initialTransform = d3.zoomIdentity.scale(2).translate(50, 30);

svgA.call(zoom.transform, initialTransform);
svgB.call(zoom.transform, initialTransform);

Без этого графики начинают с разных координатных систем, что нарушает синхронизацию при первом взаимодействии.


Проблема drift и накопления ошибок

При частой синхронизации возможны микроскопические расхождения из-за:

  • округления float
  • повторных преобразований transform
  • последовательного применения rescale

Решение:

  • всегда использовать sharedTransform как единственный источник
  • не вычислять новый transform на основе текущего DOM
  • избегать инкрементальных преобразований

Разделение модели и представления

Стабильная архитектура синхронизации zoom строится на принципе:

  • модель: transform
  • представление: SVG/Canvas
  • поведение: zoom handler
const state = {
  transform: d3.zoomIdentity
};

Все визуальные элементы реагируют только на изменение state, а не друг на друга.


Масштабирование более чем двух графиков

При N графиках применяется единый контроллер:

const views = [svgA, svgB, svgC, svgD];

function applyAll(transform) {
  views.forEach(v => {
    v.call(zoom.transform, transform);
  });
}

Это обеспечивает линейную масштабируемость архитектуры синхронизации.


Ограничение частоты обновлений

При интенсивном взаимодействии (drag + zoom) возникает поток событий. Для стабильной синхронизации применяется throttle:

let last = 0;

function handleZoom(event) {
  const now = Date.now();
  if (now - last < 16) return;

  last = now;
  sharedTransform = event.transform;

  applyAll(sharedTransform);
}

Итоговая модель взаимодействия

Синхронизация zoom между несколькими элементами сводится к трём слоям:

  • единое состояние transform
  • централизованный обработчик zoom-событий
  • применение transform ко всем представлениям

Такой подход устраняет зависимость между DOM-элементами и переводит взаимодействие в управляемую модель трансформаций координатного пространства.