Рендеринг графов на Canvas для больших наборов данных

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

Ключевая особенность Canvas — отсутствие структуры сцены. Всё, что отображается, фиксируется в виде пикселей. Это делает его значительно более производительным для задач, где важна частая перерисовка и большое количество примитивов, как в случае с графами.

Модель данных графа

Для работы с D3.js граф обычно представляется в виде двух массивов:

  • узлы (nodes)
  • связи (links)

Структура узла минимально включает идентификатор и координаты:

const nodes = [
  { id: "A" },
  { id: "B" },
  { id: "C" }
];

const links = [
  { source: "A", target: "B" },
  { source: "B", target: "C" }
];

Во время симуляции координаты x и y будут добавлены автоматически через d3-force.

Инициализация Canvas и контекста отрисовки

Работа с Canvas начинается с создания элемента и получения 2D-контекста:

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

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

Важно учитывать devicePixelRatio для корректного отображения на дисплеях с высокой плотностью пикселей:

const ratio = window.devicePixelRatio || 1;

canvas.width = window.innerWidth * ratio;
canvas.height = window.innerHeight * ratio;

context.scale(ratio, ratio);

Связка D3-force и Canvas

Механизм физической симуляции D3 остаётся тем же, что и для SVG, но рендеринг полностью переносится в Canvas.

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

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

simulation.on("tick", render);

Цикл отрисовки

Canvas требует полной перерисовки сцены на каждом обновлении:

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

  // рёбра
  context.beginPath();
  links.forEach(link => {
    context.moveTo(link.source.x, link.source.y);
    context.lineTo(link.target.x, link.target.y);
  });
  context.strokeStyle = "#aaa";
  context.stroke();

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

Такой подход исключает необходимость управления DOM-элементами и масштабируется значительно лучше.

Управление физической симуляцией

Для крупных графов важно регулировать параметры силы, чтобы избежать чрезмерной хаотичности и повысить стабильность:

d3.forceSimulation(nodes)
  .force("charge", d3.forceManyBody().strength(-20))
  .force("link", d3.forceLink(links).distance(40))
  .velocityDecay(0.4);

Уменьшение силы отталкивания и увеличение демпфирования ускоряет стабилизацию системы.

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

Canvas не ограничен количеством DOM-элементов, но ограничен производительностью CPU при частой перерисовке. Основные методы оптимизации:

Ограничение перерисовки

let isDirty = true;

function render() {
  if (!isDirty) return;
  isDirty = false;
  ...
}

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

function ticked() {
  requestAnimationFrame(render);
}
simulation.on("tick", ticked);

Минимизация операций внутри цикла

Любые вычисления, не зависящие от текущих координат, должны быть вынесены из рендера.

Масштабирование и панорамирование

Для работы с большими графами используется трансформация контекста:

const zoom = d3.zoom()
  .scaleExtent([0.1, 10])
  .on("zoom", (event) => {
    context.save();
    context.clearRect(0, 0, width, height);

    context.translate(event.transform.x, event.transform.y);
    context.scale(event.transform.k, event.transform.k);

    render();
    context.restore();
  });

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

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

Обработка взаимодействия с графом

Canvas не предоставляет встроенного hit-testing для объектов. Поэтому используется ручная проверка попадания курсора в узлы.

function findNode(mx, my) {
  return nodes.find(node => {
    const dx = node.x - mx;
    const dy = node.y - my;
    return Math.sqrt(dx * dx + dy * dy) < 5;
  });
}

Для оптимизации применяется пространственное разбиение через quadtree:

const tree = d3.quadtree()
  .x(d => d.x)
  .y(d => d.y)
  .addAll(nodes);

Поиск ближайшего узла выполняется быстрее, чем полный перебор.

Работа с подписями узлов

Текст — одна из самых дорогих операций в Canvas. При большом количестве узлов его рендеринг может резко снизить производительность.

Подходы:

  • отображение подписей только для крупных узлов
  • отрисовка текста при увеличении масштаба
  • использование отдельного слоя Canvas
if (node.degree > 5) {
  context.fillText(node.id, node.x + 6, node.y);
}

Разделение рёбер и узлов

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

  • рёбра рисуются первым слоем
  • узлы вторым
  • подписи третьим
function render() {
  drawLinks();
  drawNodes();
  drawLabels();
}

Такой порядок позволяет избежать визуального перекрытия.

Кэширование и батчинг

При большом количестве одинаковых элементов (например, узлов одного типа) можно группировать стили:

context.fillStyle = "#4682b4";
nodes.forEach(node => {
  context.beginPath();
  context.arc(node.x, node.y, 3, 0, Math.PI * 2);
  context.fill();
});

Смена fillStyle внутри цикла приводит к лишним операциям и снижает производительность.

Гибридные подходы D3 + Canvas

D3.js используется как вычислительный слой, а Canvas — как рендеринг. Это разделение позволяет сохранять удобство API D3 и производительность Canvas.

Типичная архитектура:

  • d3-force — вычисление позиций
  • quadtree — ускорение поиска
  • Canvas — отрисовка
  • d3-zoom — управление камерой

Управление жизненным циклом симуляции

Симуляцию можно останавливать после стабилизации:

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

Также применяется ручное охлаждение системы:

simulation.alphaDecay(0.02);

Производственные ограничения

При работе с десятками тысяч узлов возникают следующие ограничения:

  • CPU становится узким местом при force simulation
  • перерисовка Canvas требует полного обновления сцены
  • взаимодействие требует дополнительных структур данных
  • масштабирование требует контроля уровня детализации

Для преодоления используется:

  • отключение симуляции после стабилизации
  • уровни детализации (LOD)
  • частичная отрисовка видимой области
  • приоритизация узлов по важности