d3 и Canvas 2D API: рендеринг без DOM

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

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


Архитектурная модель: разделение вычислений и рендера

При интеграции D3 с Canvas логика делится на два уровня:

  • D3 отвечает за преобразование данных
  • Canvas отвечает за отрисовку пикселей

D3 выполняет:

  • построение шкал (scaleLinear, scaleTime, scaleOrdinal)
  • вычисление позиций элементов
  • подготовку трансформаций
  • обработку интерактивных состояний (zoom, brush, filters)

Canvas выполняет:

  • отрисовку геометрии через CanvasRenderingContext2D
  • управление буфером кадра
  • очистку и перерисовку сцены
  • композицию визуальных элементов

Такое разделение устраняет необходимость поддерживать тысячи DOM-узлов, что особенно критично для scatter plot, heatmap и сетевых графов.


Базовый цикл рендеринга

Canvas-рендеринг в D3 почти всегда строится вокруг полного перерисовывания сцены.

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

function render(data, xScale, yScale) {
  context.clearRect(0, 0, canvas.width, canvas.height);

  data.forEach(d => {
    context.beginPath();
    context.arc(xScale(d.x), yScale(d.y), 3, 0, 2 * Math.PI);
    context.fillStyle = "steelblue";
    context.fill();
  });
}

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


Интеграция шкал D3 с Canvas

Шкалы D3 остаются центральным элементом вычислительного слоя. Они трансформируют данные в пиксельные координаты, не завися от способа рендеринга.

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

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

Дальнейшая отрисовка полностью полагается на эти преобразования. Canvas не имеет встроенного понятия масштабов, поэтому вся логика координат централизуется в D3.


Отсутствие DOM и последствия для D3 data join

Механизм enter/update/exit в привычном виде теряет смысл, поскольку отсутствуют DOM-элементы. Вместо этого применяется модель полного пересчета сцены.

Типичный паттерн:

  • данные обновляются
  • пересчитываются позиции
  • сцена полностью перерисовывается
function upd ate(data) {
  render(data, xScale, yScale);
}

При этом возможно сохранение внутренней структуры состояния, но не DOM-связи.


Управление состоянием объектов

Поскольку Canvas не хранит элементы, состояние визуальных объектов переносится в отдельные структуры данных.

const state = {
  hoveredId: null,
  selectedIds: new Se t()
};

Каждый кадр использует это состояние для изменения внешнего вида элементов:

data.forEach(d => {
  context.beginPath();
  context.arc(xScale(d.x), yScale(d.y), 3, 0, 2 * Math.PI);

  if (d.id === state.hoveredId) {
    context.fillStyle = "orange";
  } else {
    context.fillStyle = "steelblue";
  }

  context.fill();
});

Интерактивность и проблема hit detection

Отсутствие DOM требует самостоятельной реализации определения попадания курсора в объект.

Простейший подход — линейный перебор:

function findNearest(x, y, data) {
  let minDist = Infinity;
  let target = null;

  data.forEach(d => {
    const dx = xScale(d.x) - x;
    const dy = yScale(d.y) - y;
    const dist = dx * dx + dy * dy;

    if (dist < minDist) {
      minDist = dist;
      target = d;
    }
  });

  return target;
}

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

  • quadtree (реализуется через d3-quadtree)
  • k-d tree
  • grid partitioning

Использование d3-quadtree для ускорения поиска

const tree = d3.quadtree()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y))
  .addAll(data);

Поиск ближайшего элемента:

const found = tree.find(mouseX, mouseY);

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


Zoom и pan без DOM

Модуль d3-zoom работает независимо от типа рендеринга. Он модифицирует трансформацию, которая затем применяется к шкалам.

const zoom = d3.zoom()
  .scaleExtent([0.5, 20])
  .on("zoom", (event) => {
    const transform = event.transform;

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

    render(data, zx, zy);
  });

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

Canvas при этом просто перерисовывается с новыми масштабами, не зная о внутренней логике трансформаций.


Оптимизация перерисовки

Полный redraw кадра может стать узким местом. Основные стратегии оптимизации:

1. Dirty rectangles (частичная перерисовка) Используется редко, так как усложняет синхронизацию состояния.

2. OffscreenCanvas Позволяет выполнять рендеринг в отдельном потоке.

3. Кэширование слоев

const buffer = document.createElement("canvas");
const bufferCtx = buffer.getContext("2d");

Статические элементы рисуются один раз, динамические — поверх.

4. Пропуск кадров

let scheduled = false;

function requestRender() {
  if (!scheduled) {
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      render(data, xScale, yScale);
    });
  }
}

Работа с тысячами и миллионами точек

Canvas становится оправданным при плотных визуализациях:

  • scatter plot с >100k точек
  • heatmap больших сеток
  • потоковые данные
  • графы с высокой плотностью ребер

SVG в таких сценариях деградирует из-за стоимости DOM-узлов, тогда как Canvas сохраняет постоянную стоимость рендера, зависящую только от количества операций отрисовки.


D3 как вычислительный слой поверх Canvas

В архитектурно чистом варианте D3 полностью отделяется от визуализации:

  • D3: генерация данных, масштабов, компоновок
  • Canvas: отображение пикселей

Пример для force-directed графа:

const simulation = d3.forceSimulation(nodes)
  .force("link", d3.forceLink(links).id(d => d.id))
  .force("charge", d3.forceManyBody())
  .force("center", d3.forceCenter(width / 2, height / 2))
  .on("tick", render);

Каждый тик симуляции вызывает перерисовку Canvas.


Рендеринг графов и связей

Линии и узлы требуют строгого порядка отрисовки:

function render(data) {
  context.clearRect(0, 0, width, height);

  // связи
  context.strokeStyle = "#aaa";
  links.forEach(l => {
    context.beginPath();
    context.moveTo(l.source.x, l.source.y);
    context.lineTo(l.target.x, l.target.y);
    context.stroke();
  });

  // узлы
  nodes.forEach(n => {
    context.beginPath();
    context.arc(n.x, n.y, 4, 0, 2 * Math.PI);
    context.fillStyle = "#4682b4";
    context.fill();
  });
}

Порядок важен, поскольку Canvas не имеет z-index слоя как DOM.


Управление качеством и плотностью пикселей

Для дисплеев с высокой плотностью пикселей требуется масштабирование:

const dpr = window.devicePixelRatio || 1;

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

context.scale(dpr, dpr);

Без этого визуализация становится размытым растровым изображением.


Ограничения Canvas в контексте D3

Несмотря на производительность, подход имеет ограничения:

  • отсутствие семантики элементов
  • невозможность CSS-стилей
  • сложная доступность (a11y)
  • ручная реализация событий
  • полная перерисовка сцены

D3 при этом не компенсирует эти ограничения, а лишь предоставляет вычислительный инструментарий.


Гибридный подход SVG + Canvas

Часто применяется комбинированная модель:

  • Canvas: массовые данные
  • SVG: интерактивные элементы управления

Например:

  • Canvas рисует scatter plot
  • SVG поверх содержит tooltip и axis

Это разделение позволяет сохранить производительность и интерактивность одновременно.


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

Типичный pipeline:

  1. загрузка данных
  2. трансформация через D3
  3. вычисление scale/layout
  4. передача в render()
  5. отрисовка Canvas
function update(rawData) {
  const processed = preprocess(rawData);

  xScale.domain(d3.extent(processed, d => d.x));
  yScale.domain(d3.extent(processed, d => d.y));

  render(processed, xScale, yScale);
}

Модель императивного сцено-ориентированного рендеринга

Canvas в связке с D3 формирует модель, где сцена описывается не как дерево объектов, а как функция состояния:

Scene = f(data, state, scales)

Каждый кадр является чистой функцией от входных параметров, что упрощает предсказуемость поведения при сложных интерактивных сценариях.