Туторіал · Теорія графів · Canvas 2D · JavaScript
📅 Липень 2026 ⏱ ≈ 25 хв 🎯 Середній рівень

Force-directed граф у Canvas 2D з нуля

Кожна "красива" візуалізація графа, яку ви бачили — дерева залежностей на GitHub, граф нотаток в Obsidian, мережеві карти в Gephi — намальована за допомогою однієї й тієї самої базової ідеї: трактувати ребра як пружини, а вузли як заряджені частинки, що відштовхуються одна від одної, а потім дати фізиці "встановити" макет. Це алгоритм spring-embedder / force-directed, і його можна побудувати з нуля в Canvas 2D менш ніж за 150 рядків JavaScript.

1. Модель даних графа

Всьому рушію укладання потрібні лише два масиви: список вузлів (кожен із позицією та швидкістю) та список ребер (пари індексів вузлів). Матриця суміжності не потрібна — списку ребер достатньо і для пружинних сил, і для відмальовки.

class GraphLayout {
  constructor(nodeCount, edgeList, width, height) {
    this.width = width;
    this.height = height;
    // Розташовуємо вузли випадково біля центру
    this.nodes = Array.from({ length: nodeCount }, () => ({
      x: width / 2 + (Math.random() - 0.5) * 100,
      y: height / 2 + (Math.random() - 0.5) * 100,
      vx: 0, vy: 0,
      fixed: false  // true, доки користувач перетягує цей вузол
    }));
    this.edges = edgeList; // [[i, j], [i, k], ...]
  }
}

// Приклад: невеликий граф, 6 вузлів, 7 ребер
const layout = new GraphLayout(6, [
  [0,1],[0,2],[1,2],[1,3],
  [2,4],[3,4],[3,5]
], 800, 600);

2. Пружинні сили на ребрах (закон Гука)

Кожне ребро трактується як пружина з природною довжиною спокою L₀. Якщо два з'єднані вузли розташовані далі, ніж L₀, пружина притягує їх; якщо ближче — відштовхує, точно за законом Гука: F = −k·(d − L₀):

function applySpringForces(nodes, edges, restLength = 80, k = 0.05) {
  for (const [i, j] of edges) {
    const a = nodes[i], b = nodes[j];
    const dx = b.x - a.x, dy = b.y - a.y;
    const dist = Math.max(Math.hypot(dx, dy), 0.01);
    const force = k * (dist - restLength);   // закон Гука: F = -k(d - L0)
    const fx = (dx / dist) * force, fy = (dy / dist) * force;
    a.vx += fx; a.vy += fy;   // тягнемо a до b
    b.vx -= fx; b.vy -= fy;   // тягнемо b до a (3-й закон Ньютона)
  }
}
Вибір restLength і k: більше restLength розкладає весь граф ширше; більше k робить ребра "жорсткішими", і макет швидше збігається, але може перевищувати рівновагу й коливатись. Типові стартові значення: restLength = 60–100px, k = 0.02–0.1.

3. Відштовхування між усіма вузлами (закон Кулона)

Самі лише пружини дозволили б незв'язаним вузлам злитися один з одним. Сила відштовхування між кожною парою вузлів (не лише зв'язаних), обернено пропорційна квадрату відстані — точно закон Кулона для однойменних зарядів — розсуває граф у зручний для читання макет:

function applyRepulsion(nodes, strength = 4000) {
  for (let i = 0; i < nodes.length; i++) {
    for (let j = i + 1; j < nodes.length; j++) {
      const a = nodes[i], b = nodes[j];
      const dx = b.x - a.x, dy = b.y - a.y;
      const distSq = Math.max(dx*dx + dy*dy, 25);  // обмеження мінімальної відстані
      const dist = Math.sqrt(distSq);
      const force = strength / distSq;   // закон Кулона: F = k / r²
      const fx = (dx / dist) * force, fy = (dy / dist) * force;
      a.vx -= fx; a.vy -= fy;   // відштовхуємо a від b
      b.vx += fx; b.vy += fy;   // відштовхуємо b від a
    }
  }
}
Це вузьке місце O(N²): подвійний цикл тут — найдорожча частина симуляції — див. розділ 8 про виправлення через Barnes-Hut, яке робить графи з тисячами вузлів практичними.

4. Центрувальна сила та демпфування

Без центрувальної сили взаємно відштовхувані вузли дрейфували б назовні назавжди без жодної причини десь зупинитись. Слабка сила до центру канви утримує весь макет закріпленим, а демпфування швидкості (тертя) — це те, що насправді дозволяє симуляції встановитись у стабільний стійкий макет замість нескінченних коливань:

function applyCenteringAndDamping(nodes, cx, cy, centerStrength = 0.01, damping = 0.85) {
  for (const n of nodes) {
    if (n.fixed) continue;  // не боремося з перетягуванням мишею
    n.vx += (cx - n.x) * centerStrength;
    n.vy += (cy - n.y) * centerStrength;
    n.vx *= damping;   // тертя: розсіює кінетичну енергію щокадру
    n.vy *= damping;
  }
}

Коефіцієнт демпфування, близький до 1.0 (напр., 0.95), дозволяє макету плавно й повільно розгойдуватись і встановлюватись; менше значення (напр., 0.7) збігається швидше, але може виглядати різко. Більшість реалізацій також поступово збільшують демпфування з часом ("симульований відпал"), щоб зафіксувати остаточний макет.

5. Цикл інтегрування

Кожен кадр анімації застосовуйте всі сили послідовно, а потім інтегруйте швидкість у позицію простим напівнеявним методом Ейлера — тим самим інтегратором, що використовується в кожній симуляції частинок:

function step(layout) {
  applySpringForces(layout.nodes, layout.edges);
  applyRepulsion(layout.nodes);
  applyCenteringAndDamping(layout.nodes, layout.width / 2, layout.height / 2);

  for (const n of layout.nodes) {
    if (n.fixed) continue;
    n.x += n.vx;
    n.y += n.vy;
  }
}
Перевірка збіжності: підсумовуйте загальну кінетичну енергію (Σ vx² + vy²) щокадру — щойно вона впаде нижче малого порогу, макет установився, і можна припинити викликати step() щокадру, заощаджуючи процесор на статичних графах.

6. Відмальовка ребер і вузлів

Відмальовка — найпростіша частина: очистіть канву, намалюйте кожне ребро як лінію, потім намалюйте кожен вузол як заповнене коло зверху:

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

  // Спочатку ребра, щоб вузли малювались поверх них
  ctx.strokeStyle = 'rgba(148,163,184,0.5)';
  ctx.lineWidth = 1.5;
  for (const [i, j] of layout.edges) {
    const a = layout.nodes[i], b = layout.nodes[j];
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }

  // Вузли зверху
  ctx.fillStyle = '#38bdf8';
  for (const n of layout.nodes) {
    ctx.beginPath();
    ctx.arc(n.x, n.y, 8, 0, Math.PI * 2);
    ctx.fill();
  }
}

// Головний цикл
function animate() {
  step(layout);
  render(ctx, layout);
  requestAnimationFrame(animate);
}
animate();

7. Інтерактивне перетягування

Перетягування вузла робить макет "живим" і є необхідним для дослідження щільних графів. На mousedown знайдіть найближчий вузол під курсором і позначте його fixed, щоб фізичний цикл пропускав його; на mousemove прив'яжіть позицію цього вузла безпосередньо до миші; на mouseup відпустіть його назад у симуляцію:

let draggedNode = null;

canvas.addEventListener('mousedown', (e) => {
  const { x, y } = getCanvasCoords(e);
  let closest = null, closestDist = 15;  // радіус влучання 15px
  for (const n of layout.nodes) {
    const d = Math.hypot(n.x - x, n.y - y);
    if (d < closestDist) { closest = n; closestDist = d; }
  }
  if (closest) { closest.fixed = true; draggedNode = closest; }
});

canvas.addEventListener('mousemove', (e) => {
  if (!draggedNode) return;
  const { x, y } = getCanvasCoords(e);
  draggedNode.x = x; draggedNode.y = y;
  draggedNode.vx = draggedNode.vy = 0;
});

addEventListener('mouseup', () => {
  if (draggedNode) draggedNode.fixed = false;
  draggedNode = null;
});

Відпускання перетягнутого вузла назад у вже встановлений макет дає йому свіжі умови нульової швидкості, тому він плавно повторно врівноважується із сусідами, а не смикається чи відлітає геть — невелика, але важлива деталь для макета, що відчувається фізично обґрунтованим.

8. Масштабування: Barnes-Hut для O(N log N) відштовхування

Наївний цикл відштовхування з розділу 3 — O(N²) за кадр: цілком прийнятно для кількох сотень вузлів, але швидко стопориться задовго до кількох тисяч. Точно як у гравітаційному N-тіловому симуляторі, апроксимація Barnes-Hut на основі квадродерева групує віддалені скупчення вузлів в один "віртуальний заряд" у їхньому спільному центрі мас, знижуючи вартість до O(N log N):

// Схема: побудуйте квадродерево щокадру, потім для кожного
// вузла обходьте дерево й трактуйте достатньо віддалений/малий
// квадрант як одну точкову масу в його центрі мас замість
// відвідування кожного вузла в ньому окремо.
function applyRepulsionBarnesHut(nodes, quadtree, theta = 0.8) {
  for (const n of nodes) {
    quadtree.visit((quad, x0, y0, x1, y1) => {
      const size = x1 - x0;
      const dx = quad.comX - n.x, dy = quad.comY - n.y;
      const dist = Math.hypot(dx, dy);
      if (size / dist < theta) {
        applyForceFrom(n, quad.comX, quad.comY, quad.mass);
        return true;  // зупиняємо спуск — трактуємо як одну масу
      }
      return false; // занадто близько/велике — спускаємось у дочірні
    });
  }
}
Коли варто турбуватись: нижче ~300 вузлів простий цикл O(N²) простіший і достатньо швидкий на 60fps. Вище цього Barnes-Hut (θ ≈ 0.7–0.9) утримує макет інтерактивним аж до нижніх тисяч вузлів — та сама деревна структура, що використовується для гравітаційної N-тілової симуляції, застосовується тут майже без змін, оскільки обидві задачі — "кожна частинка відштовхує кожну іншу частинку" — по суті ідентичні.

Часті запитання

Чого я навчуся в цьому уроці?

Створіть рушій укладання force-directed графа з нуля в Canvas 2D: пружинні сили на ребрах, кулонівське відштовхування між вузлами, центрувальна сила, демпфування швидкості та інтерактивна відмальовка з перетягуванням.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Модель даних графа, Пружинні сили на ребрах (закон Гука), Відштовхування між усіма вузлами (закон Кулона), Центрувальна сила та демпфування, Цикл інтегрування, Відмальовка ребер і вузлів, Інтерактивне перетягування, Масштабування: Barnes-Hut для O(N log N) відштовхування.

Скільки часу займає цей урок?

Цей урок займає приблизно 25 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.