В этом посте мы создадим кольцевую диаграмму SVG с интерактивными фрагментами/частями. Я начну с объяснения проблемы с наиболее распространенным способом реализации кольцевой диаграммы SVG (используя stroke-dasharray и stroke-dashoffset). Затем мы поговорим о новом способе сделать это, я подробно рассмотрю математику и использование SVG, и мы, наконец, реализуем решение с помощью React и Angular.

Проблема с реализацией stroke-dasharray

Саломон Бакис рассказывает об этом в этом посте. Этот метод использует свойства stroke-dasharray и stroke-dashoffset для рисования части границы вокруг кругов SVG. Прочитайте пост, чтобы увидеть, как он это делает.

Проблема в том, что мы не можем взаимодействовать со срезами. Например, мы не можем изменить цвет слайса при наведении или сделать что-то, когда на слайс нажимают.
Потому что для рисования слайсов нам нужно нарисовать весь круг и закрасить части его границы. Таким образом, круги накладываются друг на друга, и только последний круг является интерактивным. Думайте об этом как о расположении нескольких элементов HTML в одном месте. Элемент с наибольшим z-index скрывает все остальные элементы под ним. Посмотрите на иллюстрацию ниже и обратите внимание на ID выделенного круга (42).

Путь к спасению

Мы хотим что-то вроде этого:

Для этого мы нарисуем каждый кусочек пончика, используя <path>. Элемент <path> — самый мощный элемент в библиотеке базовых фигур SVG. Его можно использовать для создания линий, кривых, дуг и многого другого.
Форма элемента <path> определяется одним параметром: d. Атрибут d содержит набор команд и параметров, используемых этими командами (Документация здесь).

Для рисования срезов нам понадобится всего три команды:

  • M [x] [y]: Переместить в позицию x,y
  • L [x] [y]: нарисовать линию от предыдущей позиции до позиции x,y.
  • A [радиус x] [радиус y] [вращение оси x] [флаг большой дуги] [флаг развертки] [x] [y]: нарисуйте дугу strong> из предыдущей позиции в позицию x,y

Команды move и line довольно просты. Команда дуги более сложная, но не волнуйтесь, мы увидим, как использовать каждый из ее параметров.

Следите за мной в Твиттере по адресу twitter.com/theAngularGuy, поскольку я (почти) каждый день пишу в Твиттере о вещах, которые я узнал за более чем 6 лет своего пути в веб-разработке ✅

Допустим, у нас есть SVG со следующим viewBox: viewBox="0 0 100 100". Самая верхняя левая точка — 0,0, а самая нижняя правая точка — 100,100.

Давайте построим четверть круга (кратно четвертям проще всего, потому что мы знаем их координаты x, y):

<svg viewBox="0 0 100 100">
  <path fill="tomato"
             d="M 100 50
               A 50 50 0 0 0 50 0
               L 50 50"
  />
</svg>

Итак, мы переместились в точку 100, 50, затем провели дугу (радиусом 50) в точку 50, 0. И, наконец, мы провели линию обратно к центру (50, 50).

ℹ️ SVG закрывается автоматически, поэтому нам не нужно рисовать линию обратно к исходной позиции (100, 50).

Пока у нас есть это (я обвел SVG черной рамкой):

Теперь давайте поиграем с разными флагами, чтобы понять их.
Начнем с sweep-flag. Скажем, мы установили его в true (1), у нас есть это:

Дуга из выпуклой стала вогнутой. Этот флаг определяет, должна ли дуга начать движение под положительными или отрицательными углами, что, по сути, выбирает, какой из двух кругов будет проходить. Мы поговорим об этом подробнее через секунду.

Давайте перейдем к large-arc-flag, если мы установим его в 1, мы получим это:

Этот флаг определяет, должна ли дуга быть больше или меньше 180 градусов; в конце концов, этот флаг определяет, пойдем ли мы по короткому пути к точке или по длинному.

Теперь давайте включим оба этих флага. Вот что мы получаем:

Мы получаем это, потому что мы выбрали самый длинный маршрут к точке (50, 0) и движемся туда, принимая направление с отрицательным углом.

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

Тригонометрия

Срез круговой диаграммы

Допустим, нам нужно положение этой точки (под углом 45°):

Чтобы найти его положение, мы должны сделать некоторую тригонометрию. А пока отложим в сторону радиус и позиции SVG viewBox. Допустим, у нас есть круг радиусом один:

Чтобы получить координату точки, мы должны сделать cos(angle) для горизонтального положения и sin(angle) для вертикального положения. Угол должен быть в радиантах, поэтому, если мы хотим, чтобы точка располагалась под углом 45 градусов, мы должны сначала преобразовать ее в радиант. Для этого умножаем его на PI и делим на 180.
Таким образом, положение точки следующее:

const position = [ 
   Math.cos(45 * Math.PI / 180), 
   Math.sin(45 * Math.PI / 180) 
]
// [0.707106781186547, 0.707106781186547]

Теперь, если мы хотим поместить эту точку в наш SVG, мы должны преобразовать горизонтальное и вертикальное положение, чтобы убедиться, что наша точка правильно расположена в нашем круге.
Для этого мы должны умножить положение x на радиус, затем добавьте горизонтальный размер SVG. И для вертикального положения мы должны умножить его на минус радиус, а затем добавить вертикальный размер SVG. Если мы реализуем функцию для этого, мы получим следующее:

function getCoordFromDegrees(angle, radius, svgSize) {
    const x = Math.cos(angle * Math.PI / 180);
    const y = Math.sin(angle * Math.PI / 180);
    const coordX = x * radius + svgSize / 2;
    const coordY = y * -radius + svgSize / 2;
    return [coordX, coordY];
}
getCoordFromDegree(45, 50, 100); // [85.35499, 14.64500]

Если мы используем это в нашем SVG, у нас будет это:

<svg viewBox="0 0 100 100">
  <path fill="tomato"
             d="M 100 50
               A 50 50 0 0 0 85.35499, 14.64500
               L 50 50"
  />
</svg>

Срез кольцевой диаграммы

Чтобы перейти от куска пирога к кусочку пончика, нам нужно вычислить еще две точки. Вместо того, чтобы вернуться в центр, мы останавливаемся раньше. Допустим, мы хотим, чтобы граница пончика была размером 20 единиц (помните, что поле просмотра имеет размер 100 на 100), поэтому нам нужно нарисовать линию до getCoordFromDegrees(45, 30, 100) (30, потому что радиус равен 20).
Наконец, мы возвращаемся к исходное положение минус 20 горизонтальных единиц. Не забудьте установить для sweep-flag значение 1 из-за отрицательного направления.
Посмотрите на рисунок ниже:

<svg viewBox="0 0 100 100">
  <path fill="tomato"
        d="M 100 50
          A 50 50 0 0 0 85.35499 14.64500
          L 71.213 28.78700
          A 30 30 0 0 1 80 50"
  />
</svg>

Срез больше 180 градусов

Если срез больше 180 градусов, помните, что мы должны сказать SVG выбрать самый длинный маршрут (по умолчанию он выберет самый короткий). Для этого мы устанавливаем large-arc-flag на 1.

Складывание нескольких ломтиков/частей

Мы почти закончили, теперь нам нужно разместить кусочки там, где они должны быть. Допустим, первый срез — зеленый с углом 270 градусов, следующий — фиолетовый с углом 45 градусов, а затем синий — тоже с углом 45 градусов. Итак, нам нужно повернуть вторую на 270 градусов, а третью на 315 градусов (270 + 45).

ℹ️ Нам также нужно установить преобразование-происхождение path в центр (transform-origin: center;), так как мы вращаем его относительно центра.

⚠️ Имейте в виду, что вращение CSS движется по часовой стрелке, а градусы - против часовой стрелки.

Выполнение

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

export interface DonutSlice {
  id: number;
  percent: number;
  color: string;
  label?: string;
  onClickCb?: () => void;
}

interface DonutSliceWithCommands extends DonutSlice {
  // This is the offset that we will use to rotate the slices
  offset: number;
  // This will be what goes inside the d attribute of the path tag
  commands: string;
}

Реагировать

Есть миллион способов сделать это, вот как я это реализовал.
Сначала я создал вспомогательный класс для выполнения вычислений. Он преобразует каждый DonutSlice в DonutSliceWithCommands (вы можете поместить его в отдельный файл в реальном проекте). Затем я создал функциональный компонент для цикла по срезам:

  • Вы можете найти stackBlitz здесь.

Угловой

Сначала мы реализуем чистый канал, который принимает массив DonutSlice и возвращает массив DonutSliceWithCommands. Затем мы реализуем компонент, который перебирает массив DonutSliceWithCommands:

  • Вы можете найти stackBlitz здесь.

Это все для этого поста. Надеюсь, вам понравилось. Если да, поделитесь ею со своими друзьями и коллегами и подпишитесь на меня в Твиттере на @theAngularGuy, где я пишу твиты о веб-разработке и компьютерных науках.

Ваше здоровье!