Всем привет!

Недавно мне нужно было сделать 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.

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

Так что, если не хотите их пропустить - буду благодарен за подписку на мой Тг-канал, там я делюсь полезными фишками, мемами и хорошим настроением!

Комментарии (0)