При визуализации графов с большим количеством узлов и связей SVG быстро становится узким местом. Каждая вершина и каждое ребро превращаются в отдельный DOM-элемент, что приводит к резкому росту нагрузки на браузер при масштабах в тысячи и десятки тысяч элементов. Canvas решает эту проблему за счёт императивного рисования в одном растровом контексте, где количество элементов DOM не увеличивается.
Ключевая особенность Canvas — отсутствие структуры сцены. Всё, что отображается, фиксируется в виде пикселей. Это делает его значительно более производительным для задач, где важна частая перерисовка и большое количество примитивов, как в случае с графами.
Для работы с D3.js граф обычно представляется в виде двух массивов:
Структура узла минимально включает идентификатор и координаты:
const nodes = [
{ id: "A" },
{ id: "B" },
{ id: "C" }
];
const links = [
{ source: "A", target: "B" },
{ source: "B", target: "C" }
];
Во время симуляции координаты x и y будут
добавлены автоматически через d3-force.
Работа с 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 остаётся тем же, что и для 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;
...
}
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. При большом количестве узлов его рендеринг может резко снизить производительность.
Подходы:
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.js используется как вычислительный слой, а Canvas — как рендеринг. Это разделение позволяет сохранять удобство API D3 и производительность Canvas.
Типичная архитектура:
Симуляцию можно останавливать после стабилизации:
simulation.on("end", () => {
render();
});
Также применяется ручное охлаждение системы:
simulation.alphaDecay(0.02);
При работе с десятками тысяч узлов возникают следующие ограничения:
Для преодоления используется: