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

Описание процесса найма сотрудника с помощью таких нод

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

  1. Ломанные линии с прямыми углами как в UML-диаграммах. Такой вид соединений хорош, когда нам надо показывать четкие иерархии и отношения между соединяемыми объектами, для которых, зачастую, нет разницы, откуда приходит это соединение. В реальном мире это могло бы напоминать трубопровод, с различными разветвлениями и пересечениями, который соединяет резервуары.

  2. Плавные кривые, какие используют Nodes в UE4 или Shader Nodes в Blender. Они наглядно показывают не только отношения между объектами, но и их взаимодействие, а так же определяют конкретные входы и выходы для разных данных. В свою очередь эти связи можно представить как провода в аналоговом модульном синтезаторе, которые соединяют генераторы звука и множество фильтров между собой для извлечения уникального звука.

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

Реализация

Так как наше приложение не использует canvas, решение так же должно использовать возможности DOM для отображения соединений. Первый кандидат на рисование кривых - это <path /> в SVG.

Ниже условно представлено, как выглядит основное пространство, в котором происходит работа:

<div class="container">
	<div class="nodes-container">
		<!--...-->
	</div>
</div>
.container {
  position: absolute;
  width: 100%;
  height: 100%;
  top: 0px;
  left: 0px;
  overflow: hidden;
}

.nodes-container {
  position: absolute;
  top: 0px;
  left: 0px;
  width: 100%;
  height: 100%;
  transform-origin: left top;
  /* 
    Задается динамически, но здесь и далее для удобства 
    будут использованы эти значения 
  */
  transform: translate(640px, 360px) scale(0.5);
}

Поместим <svg /> в DOM выше nodes-container, чтобы он рендерился первым и находился ниже. Так же накинем ему некоторых стилей, чтобы он занимал собой все пространство и не перехватывал события, а внутри обернем все соединения в <g /> для синхронизации transform с .nodes-container.

<div class="container">
	<svg class="connections-container">
		<g transform="translate(640, 360) scale(0.5)">
			<!--...-->
		</g>
	</svg>
	<div class="nodes-container">
		<!--...-->
	</div>
</div>
.container {
  /* ... */
}

.nodes-container {
  /* ... */
}

.connections-container {
  pointer-events: none;
  overflow: hidden;
  position: absolute;
  width: 100%;
  height: 100%;
  transform-origin: left top;
}

На этом подготовка закончена и можно перейти к отрисовке самих соединений. Для начала соединим порты прямыми линиями, чтобы разобраться с их позиционированием. У элемента <path/> есть атрибут d, в котором описывается геометрия фигуры. Для прямой линии достаточно двух команд - “Move to” - M и “Line to” - L. Первая указывает точку, от которой начинается рисование фигуры, вторая - рисует линию до следующей точки. Обе команды имеют синтаксис следующего вида:

M x, y
L x, y

Нам известны центры портов в формате {x, y}, поэтому для соединения точек {x: 20, y: 60} и { x: 45, y: 90 } выражение d будет выглядеть как:

M 20, 60 L 45, 90

<path /> надо добавить еще несколько свойств, чтобы избежать заполнения фигуры, а также указать цвет и толщину самой линии:

<path 
  d="M 20 60 L 45 90" 
  fill="transparent"
  stroke="rgba(0, 0, 0, 0.2)"
  stroke-width="1"
></path>

Теперь пора добавить красоты и сделать естественный изгиб получившимся линиям для случаев, когда порты находятся на разной высоте. Для этого мы воспользуемся связкой из двух квадратичных кривых Безье. В результате мы должны получить кривую, которая по форме будет напоминать букву S, так как порты нод могут находится слева и справа, но не сверху или снизу. Квадратичная кривая Безье задается тремя контрольными точками P₀ (начальная), P₁ (контрольная) и P₂ (конечная), а ее уравнение выглядит следующим образом:

Для отображения такой кривой в d используется команда Q с аргументами P₁ и P₂. В свою очередь точка P₀ определяется предыдущей командой выражения d, таковой в нашем случае является M , указывающая точку начала фигуры. Таким образом получается половина необходимой линии.

M x0, y0 Q x1, y1 x2, y2

Для того, чтобы нарисовать вторую половину - такую же кривую, отраженную по горизонтали, достаточно воспользоваться командой T. Эта команда принимает в качестве аргумента лишь одну точку P₂ для уравнения. P₀ для нее является конечная точка предшествующей кривой, а P₁ рассчитывается как отражение предыдущей контрольной точки относительно текущей P₀. Иными словами, линия продолжается в виде отражения предыдущей кривой Безье до указанной точки.

M x0, y0 Q x1, y1 x2, y2 T x3, y3

Давайте напишем функцию, для генерации необходимого выражения d. Нам известны точки {x0, y0} и {x3, y3} - это координаты портов выхода и входа. Точка {x2, y2} - будет являться центром прямой между этими двумя точками.

type Point = {
  x: number,
  y: number
};

function calculatePath(start: Point, end: Point) {
  const center = {
    x: (start.x + end.x) / 2,
    y: (start.y + end.y) / 2,
  };

  return `
    M ${start.x},${start.y} 
    Q x1, y1 ${center.x},${center.y} 
    T ${end.x},${end.y}
  `;
}

Остается рассчитать контрольную точку {x1, y1}. Для этого мы будем смещать точку начала линии по оси X. Изначальный y необходимо оставить, чтобы у точек входа и выхода линия стремилась к горизонтальному положению. Для расчета смещения возьмем минимум из дистанции между точками start и end, половины расстояния по оси Y, а так же ограничения в 150, чтобы избежать чрезмерного растяжения кривой при больших удалениях нод друг от друга.

type Point = {
  x: number,
  y: number
}

function distance(start: Point, end: Point)
{
  const dx = to.x - from.x
  const dy = to.y - from.y

  return Math.sqrt(dx * dx + dy * dy)
}

function calculatePath(start: Point, end: Point) {
	const center = {
      x: (start.x + end.x) / 2,
      y: (start.y + end.y) / 2,
	}

	const controlPoint = {
      x: start.x + Math.min(
          distance(start, end),
          Math.abs(end.y - start.y) / 2,
          150
      ),
      y: start.y,
	};

	return `
      M ${start.x},${start.y} 
      Q ${controlPoint.x}, ${controlPoint.y} ${center.x},${center.y} 
      T ${end.x},${end.y}
    `;
}

При таком расчете контрольной точки, если порты будут находиться на одной высоте, линия будет прямой, но будет искривляться пропорционально удалению нод друг от друга.

Красота!

Заключение

Данный способ отрисовки соединения справедлив для нод, чьи порты располагаются на противоположных сторонах. Однако, для портов, расположенных на одной стороне, можно использовать кубические кривые Безье, добавив такой же расчет второй контрольной точки, который будет использовать смещение от конечной.

Спасибо, что прочитали эту статью, надеюсь вам было интересно!

Комментарии (14)


  1. nikolau
    00.00.0000 00:00
    +2

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


    1. Iskorkin Автор
      00.00.0000 00:00
      +10

      Мне кажется, тема перетаскивания блоков и масштабирования рабочей области заслуживает отдельной статьи, очень уж много кроме пересчета кривой там происходит :)

      Если интересно - я напишу об этом


      1. nikolau
        00.00.0000 00:00

        Вещь, определено, нужная и полезная для применения.


      1. Artima
        00.00.0000 00:00

        Будем ждать, очень полезно


    1. savostin
      00.00.0000 00:00

      Не совсем чистый SVG, а D3, но вполне наглядно тут.


  1. Alexandroppolus
    00.00.0000 00:00

    Данный способ отрисовки соединения справедлив для нод, чьи порты располагаются на противоположных сторонах. Однако, для портов, расположенных на одной стороне, можно использовать кубические кривые Безье

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


  1. k0de
    00.00.0000 00:00

    А зум работает в вашем приложении?


    1. Iskorkin Автор
      00.00.0000 00:00

      Работает :)

      Скоро и про масштабирование, и перемещение расскажу


  1. k0de
    00.00.0000 00:00

    Кстати, а почему всё таки через DOM, а не через canvas?


    1. Iskorkin Автор
      00.00.0000 00:00

      Мы делаем всю систему на реакте, используя дизайн-систему antd. Ноды имеют режим редактирования - в этом состоянии они превращаются в огромные формы. Проще использовать все возможности DOM, React и antd, чем делать все это в canvas


      1. k0de
        00.00.0000 00:00

        Понял вас, спасибо за пояснение. Ждем следующей статьи про масштаб и перемещение)


  1. idmx
    00.00.0000 00:00

    Безумно интересная статья. Если честно, даже не подозревал, что можно таким способом реализовывать описанные вещи, так как мало с таким сталкивался. Спасибо вам)


  1. mirukutako
    00.00.0000 00:00

    А вы прям всю функциональность с нуля пишете? Работаю над проектом с похожей концепцией, но за основу взяли rete.js


    1. Iskorkin Автор
      00.00.0000 00:00

      Сами, все сами