Всем привет!
Недавно мне нужно было сделать 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.
Если статья показалась вам интересной, то у меня в планах еще много таких.
Так что, если не хотите их пропустить - буду благодарен за подписку на мой Тг-канал, там я делюсь полезными фишками, мемами и хорошим настроением!