Как рисовать красивые соединения с помощью SVG

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

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

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

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

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

a67d9d6fd9ca32a6bfcb18c729df6b6b.jpg

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

Реализация

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

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

.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);
}

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

.container {
  /* ... */
}

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

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

На этом подготовка закончена и можно перейти к отрисовке самих соединений. Для начала соединим порты прямыми линиями, чтобы разобраться с их позиционированием. У элемента есть атрибут 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

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

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

6abd1ed0d76d77835726e3ae100159ba.png3e4449c05f0795af1e976f74e3354436.png

Для отображения такой кривой в 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}
    `;
}

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

Красота!

Заключение

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

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

© Habrahabr.ru