Всем привет!
Недавно мне нужно было сделать Semi Donut Chart, я поискал реализации в интернете те, которые мне подходили были в библиотеках по типу Chart.js, а библиотеки мне очень не хотелось тащить, так как они сильно влияют на размер бандла и производительность сайта.
И тут я решил сделать свою. У меня было два варианта:
Реализовать график с помощью css
Реализовать график с помощью 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 для всех окружностей:
Как я уже писал ранее, первое это сколько заливаем, второе это все остальное, и тут все просто, алгоритм действий таков:
Предварительно рассчитываем суммарный процент отступов, обозначим за
Перебираем все проценты наших секторов, обозначим за
Возвращаем массив с двумя значениями, первое это сколько залить -
второе это все, что осталось -
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 для всех окружностей:
Как я уже говорил ранее, это начало каждого из наших секторов, алгоритм действий таков:
Перебираем stroke-dasharray's полученные на предыдущем шаге
Вспомним, что мы приняли текущее 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.
Если статья показалась вам интересной, то у меня в планах еще много таких.
Так что, если не хотите их пропустить - буду благодарен за подписку на мой Тг-канал, там я делюсь полезными фишками, мемами и хорошим настроением!
