Зум на Canvas

Интерактивное масштабирование в связке с Canvas требует разделения двух уровней ответственности: управление состоянием трансформации и ручной перерисовки сцены. В отличие от SVG, где трансформации могут быть применены к DOM-элементам напрямую, Canvas остаётся императивным контекстом рисования, что делает контроль зума полностью зависимым от логики приложения.

Библиотека D3.js предоставляет модуль d3-zoom, который работает не с рендерингом, а с абстрактным состоянием трансформации: масштабом и смещением. Это ключевой момент — Canvas не знает о зуме, он лишь перерисовывается с учётом текущей трансформации.


Модель трансформации зума

Состояние зума в D3 представляется объектом трансформации:

  • k — коэффициент масштаба
  • x, y — смещение

Эта модель применяется ко всем координатам сцены:

[ x’ = kx + t_x] [ y’ = ky + t_y]

В Canvas это не применяется автоматически, поэтому вся логика должна быть отражена в отрисовке.


Базовая структура Canvas + D3 Zoom

Основная архитектура состоит из трёх компонентов:

  • Canvas элемент
  • Контекст рисования (CanvasRenderingContext2D)
  • Поведение зума (d3.zoom)
import * as d3 from "d3";

const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

const width = canvas.width = 900;
const height = canvas.height = 600;

let transform = d3.zoomIdentity;

Состояние transform хранит текущий зум и обновляется при каждом взаимодействии пользователя.


Подключение поведения zoom

Механизм управления масштабом создаётся через d3.zoom():

const zoom = d3.zoom()
  .scaleExtent([0.5, 20])
  .on("zoom", handleZoom);

d3.select(canvas).call(zoom);

Функция handleZoom — центральная точка обновления состояния.


Обработка события zoom

Каждое движение колесика мыши или жест масштабирования вызывает обновление transform:

function handleZoom(event) {
  transform = event.transform;
  render();
}

Важно: здесь не происходит никакой работы с Canvas напрямую, только обновление состояния.


Рендер сцены с учётом трансформации

Перерисовка Canvas должна учитывать масштаб и смещение вручную:

function render() {
  ctx.save();

  ctx.clearRect(0, 0, width, height);

  ctx.translate(transform.x, transform.y);
  ctx.scale(transform.k, transform.k);

  drawScene(ctx);

  ctx.restore();
}

Ключевой момент — порядок операций:

  • сначала смещение
  • затем масштабирование
  • затем отрисовка сцены

Логика отрисовки объектов

Все объекты сцены рисуются в «мировых координатах», не учитывая зум:

function drawScene(ctx) {
  for (const node of nodes) {
    ctx.beginPath();
    ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);
    ctx.fill();
  }
}

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


Координатные преобразования

Для интерактивных сценариев часто требуется обратное преобразование координат мыши:

function getWorldPosition(event) {
  const rect = canvas.getBoundingClientRect();

  const x = (event.clientX - rect.left - transform.x) / transform.k;
  const y = (event.clientY - rect.top - transform.y) / transform.k;

  return { x, y };
}

Это позволяет:

  • определять попадание в объекты
  • добавлять новые элементы
  • выделять узлы графа

Связь zoom и осей координат

При использовании осей важно пересчитывать шкалы:

const xScale = d3.scaleLinear().domain([0, 100]).range([0, width]);
const yScale = d3.scaleLinear().domain([0, 100]).range([height, 0]);

Однако при зуме через Canvas проще работать иначе: масштабировать не scale, а сам контекст.

Если требуется обновление шкал:

const zx = transform.rescaleX(xScale);
const zy = transform.rescaleY(yScale);

Это полезно при смешанных визуализациях (например, графики + Canvas).


Ограничение области зума

Часто требуется ограничить панорамирование:

const zoom = d3.zoom()
  .scaleExtent([1, 10])
  .translateExtent([[0, 0], [width, height]])
  .on("zoom", handleZoom);
  • scaleExtent ограничивает масштаб
  • translateExtent ограничивает смещение

Инверсия трансформации и hit-testing

Для сложных сцен необходимо проверять попадание мыши в объекты:

function isPointInNode(node, point) {
  const dx = node.x - point.x;
  const dy = node.y - point.y;
  return dx * dx + dy * dy < node.r * node.r;
}

Перед этим координаты приводятся к мировым через inverse transform.


Производительность при зуме

Canvas требует полной перерисовки сцены при каждом изменении трансформации. Это накладывает ограничения:

  • избегать тяжёлых вычислений в render
  • минимизировать аллокации объектов
  • использовать заранее подготовленные массивы данных

Оптимизация часто включает:

  • кэширование геометрии
  • использование offscreen canvas
  • упрощение LOD (Level of Detail)

Использование requestAnimationFrame

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

let needsRender = false;

function scheduleRender() {
  if (!needsRender) {
    needsRender = true;
    requestAnimationFrame(() => {
      needsRender = false;
      render();
    });
  }
}

function handleZoom(event) {
  transform = event.transform;
  scheduleRender();
}

Это снижает количество перерисовок при быстрых жестах.


Комбинированные сцены: Canvas + SVG overlay

Распространённый подход — Canvas для отрисовки данных и SVG для UI:

  • Canvas: графы, точки, линии
  • SVG: подсказки, тултипы, контролы

SVG-слой не масштабируется вместе с Canvas, а синхронизируется через transform:

svg.attr("transform", transform);

Работа с высокоразрешёнными экранами

Для Retina-дисплеев важно учитывать devicePixelRatio:

const dpr = window.devicePixelRatio || 1;

canvas.width = width * dpr;
canvas.height = height * dpr;

ctx.scale(dpr, dpr);

Зум при этом применяется поверх масштабирования устройства, поэтому порядок трансформаций критичен.


Интеграция с графами и сетями

При визуализации графов (force layout) зум часто применяется поверх симуляции:

simulation.on("tick", () => {
  render();
});

Здесь позиции узлов обновляются отдельно, а Canvas только отображает текущее состояние с учётом transform.


Частые архитектурные ошибки

  • применение transform к данным вместо контекста Canvas
  • попытка использовать SVG-подходы без адаптации
  • отсутствие разделения state/render
  • игнорирование inverse transform для событий мыши
  • смешивание координатных систем

Каждая из этих ошибок приводит к рассинхронизации визуального состояния и логики сцены.


Паттерн разделения состояния

Устойчивый подход к архитектуре:

  • state — данные сцены
  • transform — состояние камеры
  • render() — чистая функция отрисовки
  • handlers — обновление transform и state

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


Итеративное обновление сцены

При сложных сценах полезно разделять слои отрисовки:

  • фон
  • сетка
  • данные
  • оверлей

Каждый слой может перерисовываться с разной частотой, но все они используют один transform, обеспечивая согласованное масштабирование.