Как сделать кастомный Semi Donut Chart с помощью SVG

от автора

Всем привет!

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

И тут я решил сделать свою. У меня было два варианта:

  1. Реализовать график с помощью css

  2. Реализовать график с помощью svg

Так как я давно хотел попробовать на что способен svg, решил выбрать именно этот вариант.

И первое с чего я начал это посмотрел как это реализовывали другие, и вот что я увидел, люди берут <circle /> и с помощью наложения и частичного их заполнения делают такие графики.

Далее мне нужно было разобраться, что такое <circle/> и с чем его едят. Итак circle — это элемент SVG, который используется для создания круговых форм. Он определяет круг по координатам его центра и радиусу. Круг может быть заполнен цветом или градиентом, а также может иметь обводку или тень.

Это база

Для начала введу одну формулу, которая нам дальше понадобится:
C = 2 * PI * r — длина окружности

Также нужно отметить как работает длина окружности, где и какая у окружности длина, но нам нужна будет только верхняя полуокружность, на рисунке от C/2 до C.

Где и какая длина окружности

Где и какая длина окружности

В нашем случае окружность будет выглядеть чуть иначе, за C мы примем C/2, чтобы проще было производить вычисления:

Нужная нам полуокружность

Нужная нам полуокружность

Атрибуты <circle />

Теперь рассмотрим атрибуты которые есть у circle и которые мы будем использовать:

  • stroke — цвет нашего stroke, можно считать что это border окружности

  • fill — заливка нашего circle, цвет заполняет все кроме stroke

  • cx — координата по x, где будет располагаться центр нашей окружности в нашей svg области

  • cy — координата по y, где будет располагаться центр нашей окружности в нашей svg области

  • r — радиус окружности

  • stroke-offset — откуда начнется заливка нашего stroke, считается относительно длины окружности — C

  • stroke-dasharray — [сколько заливать, сколько не заливать], считается относительно длины окружности — С

  • stroke-width — ширина stroke, свойство похоже на border-width

Также хочу заметить, что z-index в svg нет, зато все элементы которые находятся выше в DOM дереве будут находится выше и на нашей страницы.

Код на Vue 3, скриптовая часть

Начнем с props’ов которые понадобятся нам для конфигурации нашего графика.

props:

const props = defineProps({   // Проценты которые нужно отразить на графике   percentage: {     type: Array as PropType<number[]>,     default: () => [],   },   // Высота нашей диаграммы   height: {     type: Number,     default: 128,   },   // Ширина нашей диаграммы   width: {     type: Number,     default: 256,   },   // Ширина сектора диаграммы, читай как border-width   strokeWidth: {     type: Number,     default: 30,   },   // Цвета для наших секторов   sectorColors: {     type: Array as PropType<string[]>,     default: () => [],   },   // Отступ между секторами   gap: {     type: Number,     default: 20,   }, });

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

Находим координаты центра графика:

Они будут равны width / 2 и height / 2.

const cx = computed<number>(() => props.width / 2); const cy = computed<number>(() => props.height / 2);

Находим радиус окружности:

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

const r = computed<number>(() => props.width / 2 - props.strokeWidth / 2);

Находим длину окружности:

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

const C = computed<number>(() => Math.PI * r.value);

Находим отступ между секторами не в процентах, а в числе относительно длины окружности, по формуле (C * процент отступа) / 100

const computedGap = computed<number>(() => (C.value * props.gap) / 100);

Находим stroke-dasharray для всех окружностей:

Как я уже писал ранее, первое это сколько заливаем, второе это все остальное, и тут все просто, алгоритм действий таков:

  1. Предварительно рассчитываем суммарный процент отступов, обозначим за sum(gapPercentage) = gap * (len(percentage) - 1) / 100

  2. Перебираем все проценты наших секторов, обозначим за currentSectorPercentage

  3. Возвращаем массив с двумя значениями, первое это сколько залить — (C * (1 - sum(gapPercentage)) * currentSectorPercentage) / 100
    второе это все, что осталось — (C * (1 - (1 - sum(gapPercentage)) * (currentSectorPercentage / 100))

const strokeDashArrays = computed<number[][]>(() => {   const sumGapPercentage = (props.gap * (props.percentage?.length - 1)) / 100;   return props.percentage.map((percent) => {     return [       (C.value * (1 - sumGapPercentage) * percent) / 100,       C.value * (1 - (percent / 100) * (1 - sumGapPercentage)),     ];   }); });

Находим stroke-dasharray для всех окружностей:

Как я уже говорил ранее, это начало каждого из наших секторов, алгоритм действий таков:

  1. Перебираем stroke-dasharray’s полученные на предыдущем шаге

  2. Вспомним, что мы приняли текущее C, за C/2 целой окружности, а значит начало из C значит ничто иное, как начало из C/2 изначальной окружности. Возвращаем разность, которая для каждого следующего будет длина окружности за вычетом длины всех остальных секторов до этого сектора и за вычетом всех отступов до этого сектора, заметим, что для первого элемента отступ вычитать не нужно

const strokeDashOffsets = computed<number[]>(() => {   return strokeDashArrays.value.map((value, index) => {     return strokeDashArrays.value     // Берем все элементы до текущего       .slice(0, index)     // Начинаем с C, так как первый элемент должен стоять ровно в начале тоесть на C       .reduce((acc, item) => (index >= 1 ? acc - item[0] - computedGap.value : acc - item[0]), C.value);   }); });

Метод для вычисления цвета сектора:

Тут все просто, берем из массива наших цветов, элемент с определенным индексом:

const calculateColor = (index: number) => {   return props.sectorColors[index]; };

Код на Vue 3, разметка

Итак сначала нам нужна окружность, которая будет служить задним фоном для нашего графика, зачем это ? Чтобы мы могли менять цвет наших отступов, а также мы могли делать не на 100% заполненные графики.

Заполнение прозрачное, так как наш график это наш stroke, соответственно stroke даем такой цвет, который хотим, чтобы был нашим задним фоном у графика, cx, cy, r, strokeWidth, подставляем из полученных выше параметров, stroke-dashoffset выставляем C, которое мы ранее приняли за C/2 изначальной окружности, т.е. это начало нашей полуокружности, stroke-dasharray — заливаем ровно половину окружности, т.е. нашу верхнюю полуокружность, тут тоже помним, что мы работаем с целой окружностью поэтому C заливаем и C не заливаем.

Важно отметить, что мы ставим этот <circle /> первым в DOM, чтобы он был ниже всех остальных на странице.

<circle   fill="transparent"   stroke="#b9cad1"   :cx="cx"   :cy="cy"   :r="r"   :stroke-dasharray="[C, C].join(', ')"   :stroke-dashoffset="C"   :stroke-width="props.strokeWidth" />

Далее идут все остальные наши сектора, тут все просто, перебираем все наши полученные strokeDashOffsets, и для каждого item, выставляем по стандарту fill, cx, cy, r, stroke-width, stroke — цвет который мы вычисляем с помощью функции от текущего индекса, stroke-dasharray — берем из массива по индексу, stroke-dashoffset — подставляем текущий.

<circle   v-for="(item, index) in strokeDashOffsets"   :key="`${item}_${index}`"   fill="transparent"   :cx="cx"   :cy="cy"   :r="r"   :stroke="calculateColor(index)"   :stroke-dasharray="strokeDashArrays[index].join(', ')"   :stroke-dashoffset="item"   :stroke-width="props.strokeWidth" />

Итого получаем вот такой компонент:

<script lang="ts" setup> import { computed, PropType } from 'vue';  const props = defineProps({   percentage: {     type: Array as PropType<number[]>,     default: () => [],   },   height: {     type: Number,     default: 128,   },   width: {     type: Number,     default: 256,   },   strokeWidth: {     type: Number,     default: 30,   },   sectorColors: {     type: Array as PropType<string[]>,     default: () => [],   },   gap: {     type: Number,     default: 0.4,   }, });  const cx = computed<number>(() => props.width / 2); const cy = computed<number>(() => props.height / 2); const r = computed<number>(() => props.width / 2 - props.strokeWidth / 2); const C = computed<number>(() => Math.PI * r.value); const computedGap = computed<number>(() => (C.value * props.gap) / 100);  const strokeDashArrays = computed<number[][]>(() => {   const sumGapPercentage = (props.gap * (props.percentage?.length - 1)) / 100;   return props.percentage.map((percent) => {     return [       (C.value * (1 - sumGapPercentage) * percent) / 100,       C.value * (1 - (percent / 100) * (1 - sumGapPercentage)),     ];   }); });  const strokeDashOffsets = computed<number[]>(() => {   return strokeDashArrays.value.map((value, index) => {     return strokeDashArrays.value       .slice(0, index)       .reduce((acc, item) => (index >= 1 ? acc - item[0] - computedGap.value : acc - item[0]), C.value);   }); });  const calculateColor = (index: number) => {   return props.sectorColors[index]; }; </script>  <template>   <div>     <svg       xmlns="http://www.w3.org/2000/svg"       :height="props.height"       :viewBox="`0 ${-(props.height / 2)} ${props.width} ${props.height}`"       :width="props.width"     >       <circle         fill="transparent"         stroke="#b9cad1"         :cx="cx"         :cy="cy"         :r="r"         :stroke-dasharray="[C, C].join(', ')"         :stroke-dashoffset="C"         :stroke-width="props.strokeWidth"       />        <circle         v-for="(item, index) in strokeDashOffsets"         :key="`${item}_${index}`"         fill="transparent"         :cx="cx"         :cy="cy"         :r="r"         :stroke="calculateColor(index)"         :stroke-dasharray="strokeDashArrays[index].join(', ')"         :stroke-dashoffset="item"         :stroke-width="props.strokeWidth"       />     </svg>   </div> </template>

Полученный результат:

Ваш сочный график

Ваш сочный график

Вот так у меня получился довольно гибкий и конфигурируемый half-donut-chart, в меньше чем 100 строк, также сюда можно прикрутить анимацию с помощью svg <animate />, анимируя свойства visibility, stroke-dasharray, stroke-dashoffset.

Если статья показалась вам интересной, то у меня в планах еще много таких. Так что, если не хотите их пропустить — буду благодарен за подписку на мой Тг-канал.


ссылка на оригинал статьи https://habr.com/ru/articles/741214/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *