Круговые и пончиковые диаграммы – отличный способ визуализировать доли и пропорции. Их можно встретить в инфографике, дашбордах и самых разных отчётах. И хотя готовых библиотек для визуализаций сейчас предостаточно, иногда хочется сделать что-то своё – простое, лёгкое, понятное и без зависимости от фреймворков.
В этой статье мы пошагово создадим пончиковую диаграмму (donut chart) с помощью чистого JavaScript и SVG, без использования внешних библиотек. Мы разберём:
- как работает
stroke-dasharray
иstroke-dashoffset
, - как вычислить длину дуги сегмента,
- как задать отступы между секторами.
И, конечно же, по ходу дела вспомним школьную формулы вроде длины окружности и даже немного тригонометрии! Да, вот она – та редкая ситуация, когда математика действительно пригодилась в жизни!
Что у нас получится в итоге
Прежде чем углубляться в код, давайте посмотрим, что мы собираемся построить:
- красивая пончиковая диаграмма с разноцветными сегментами;
- настраиваемый внешний и внутренний радиус;
- отступы между секторами;
- суммарное значение внутри диаграммы.
И всё это – с помощью всего одного класса Chart
. Вот пример того, как будет выглядеть использование этого класса:
const config = { svg: document.getElementById("chart"), radius: {outer: 100, inner: 60}, gap: {size: 3, color: "#fff"}, // отступ между сегментами startAngle: -90, // откуда начинается диаграмма (в градусах) label: {size: 60, color: "#333"} // параметры итогового значения } const data = [ {value: 180, color: "#f39c12"}, {value: 95, color: "#2ecc71"}, {value: 150, color: "#e74c3c"}, {value: 75, color: "#3498db"} ] const chart = new Chart(config) chart.plot(data)
Теперь, когда стало ясно, как будет выглядеть конечный результат, давайте разберёмся, как построить всё это шаг за шагом.
Шаг 0. Подготовка
Создадим два файла: chart.html
и chart.js
:
- В
chart.html
мы разместим HTML-разметку и подключим скриптchart.js
. - В
chart.js
будет сам класс Chart, который мы постепенно будем наполнять.
Вот как может выглядеть стартовый шаблон chart.html
:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Пончиковая диаграмма</title> </head> <body> <svg width="210" height="210" id="chart"></svg> <script src="chart.js"></script> <script></script> </body> </html>
Теперь создадим файл chart.js
и напишем шаблон класса Chart
:
class Chart { constructor(config) { this.svg = config.svg this.radius = { outer: config.radius.outer, inner: config.radius.inner ?? 0 // если не передать внутренний радиус, то будет круговая диаграмма, а не пончиковая } this.gap = {size: config.gap.size ?? 1, color: config.gap.color ?? "#ffffff"} this.startAngle = config.startAngle ?? 0 this.label = config.label } plot(data) { } }
Сейчас мы просто создали каркас, в котором всего лишь принимаем переданную конфигурацию. В следующем разделе мы начнём рисовать сами сегменты диаграммы. Именно там формула окружности наконец-то вступит в игру!
Шаг 1. Отрисовка сегментов
Переходим к самому интересному – отрисовке сегментов диаграммы. Но для начала немного теории.Как вообще рисуется круг в SVG?
В SVG для создания окружностей используется элемент <circle>. Но мы не будем просто рисовать замкнутые круги – мы хотим рисовать лишь части круга, то есть сегменты. И для этого пригодится особенность SVG: можно управлять тем, сколько "пробежит" линия по окружности, с помощью параметров:
stroke
: задаёт цвет обводки окружности.stroke-width
: задаёт толщину обводки окружности в пикселях (в нашем случае это разность между радиусами).stroke-dasharray
: задаёт, насколько длинным будет видимая часть обводки (дуги).stroke-dashoffset
: задаёт, с какого места по окружности начинать обводку.
Чтобы превратить значения в длину дуги, нам нужно знать длину окружности. А она считается по известной формуле: l = 2πR
. Зная длину окружности не составит труда вычислить и длину сегмента: нужно всего лишь умножить её на долю, занимаемую отрисовываемым значением. Ещё нам потребуется знать координаты центра svg, чтобы размещать в них окружности. Чтобы не перерасчитывать одни и те же значения многократно, дополним наш конструктор:
Обновлённый конструктор класса Chartconstructor(config) { this.svg = config.svg this.radius = { outer: config.radius.outer, inner: config.radius.inner ?? 0 } this.gap = {size: config.gap.size ?? 1, color: config.gap.color ?? "#ffffff"} this.startAngle = config.startAngle ?? 0 this.label = config.label // размещать окружности будем по центру svg this.x = this.svg.clientWidth / 2 this.y = this.svg.clientHeight / 2 // обводка рисуется в обе стороны, так что нам нужен средний радиус this.radius.middle = (this.radius.inner + this.radius.outer) / 2 this.strokeWidth = this.radius.outer - this.radius.inner this.length = 2 * Math.PI * this.radius.middle }
Для добавления окружностей создадим метод addCircle
:
addCircle() { let circle = document.createElementNS("http://www.w3.org/2000/svg", "circle") circle.setAttribute("cx", this.x) circle.setAttribute("cy", this.y) circle.setAttribute("r", this.radius.middle) circle.setAttribute("stroke-width", this.strokeWidth) circle.setAttribute("fill", "none") this.svg.appendChild(circle) return circle }
И наконец добавим окружности на диаграмму
plot(data) { for (let item of data) { let circle = this.addCircle() circle.setAttribute("stroke", item.color) } }
Если добавить в chart.html
код из самого начала статьи, то при запуске получим следующий вид:
Это пока совсем не похоже на диаграмму для четырёх элементов, но мы исправим это в следующем шаге.
Шаг 2. Управляем сегментами
Для расчёта длин сегментов, нам нужно знать доли, занимаемые каждым из переданных значений, а для этого сначала нам нужно посчитать суммарное значение, а затем поделить переданное значение на общую сумму. Рассчитав доли и длину сегмента, останется только задать свойство stroke-dasharray
из двух значений: длину закрашенной области и длину незакрашенной (оставшаяся длина от всей окружности):
plot(data) { let total = data.reduce((sum, item) => sum + item.value, 0) // считаем суммарное значение for (let item of data) { let value = item.value / total // считаем долю let circle = this.addCircle() circle.setAttribute("stroke", item.color) circle.setAttribute("stroke-dasharray", [value * this.length, (1 - value) * this.length]) } }
Но погодите, у нас получилась совсем не диаграмма. Все сегменты начинаются в одном и том же месте:
Это произошло потому, что мы не задали начальное смещение с помощью аттрибута stroke-dashoffset
. Чтобы его рассчитать, нам нужно знать суммарную длину уже отрисованных сегментов. Также необходимо не забыть учесть и начальный угол, переданный в конфиге:
plot(data) { let total = data.reduce((sum, item) => sum + item.value, 0) // считаем суммарное значение let offset = this.startAngle / 360 for (let item of data) { let value = item.value / total // считаем долю let circle = this.addCircle() circle.setAttribute("stroke", item.color) circle.setAttribute("stroke-dasharray", [value * this.length, (1 - value) * this.length]) circle.setAttribute("stroke-dashoffset", -offset * this.length) offset += value } }
В итоге получим такую красивую диаграмму:
Для удобства и единообразия управления аттрибутами окружностей, перенесём параметры color
, value
и offset
внутрь метода добавления окружностей:
plot(data) { ... for (let item of data) { let value = item.value / total // считаем долю this.addCircle(item.color, value, offset) offset += value } } addCircle(color, value, offset) { let circle = document.createElementNS("http://www.w3.org/2000/svg", "circle") circle.setAttribute("cx", this.x) circle.setAttribute("cy", this.y) circle.setAttribute("r", this.radius.middle) circle.setAttribute("stroke", color) circle.setAttribute("stroke-width", this.strokeWidth) circle.setAttribute("stroke-dasharray", [value * this.length, (1 - value) * this.length]) circle.setAttribute("stroke-dashoffset", -offset * this.length) circle.setAttribute("fill", "none") this.svg.appendChild(circle) }
Шаг 3. Отступы
Сейчас сегменты идут плотно друг за другом, а параметр gap
вовсе никак не используется. Чтобы это исправить, можно было бы уменьшать длину сегментов на величину этого отступа, однако тогда зазоры будут иметь разную ширину между внешней и внутренней дугами. Поэтому вместо этого мы добавим на местах соединения сегментов прямые линии.
Но как узнать координаты этих линий? Внезапно здесь нам поможет школьная тригонометрия. Любая точка на окружности может быть определена с помощью следующих уравнений: x = x0 + radius * cos(angle)
и y = y0 + radius * sin(angle)
.
Для линии нам нужны две точки: начало (x1
, y1
) и конец (x2
, y2
). Началом линии будет точка на внутренней дуге, а конечной – точка на внешней дуге. Центр мы знаем – это центр svg. Остаётся понять, как найти угол. С углом на самом деле тоже всё просто: это всего лишь смещение, умноженное на 2π
.
Соединим полученные знания:
plot(data) { ... let offsets = [offset] for (let item of data) { ... offsets.push(offset) } for (offset of offsets) this.addDivider(offset) } addDivider(offset) { let line = document.createElementNS("http://www.w3.org/2000/svg", "path") let angle = 2 * Math.PI * offset let x1 = this.x + this.radius.inner * Math.cos(angle) let y1 = this.y + this.radius.inner * Math.sin(angle) let x2 = this.x + this.radius.outer * Math.cos(angle) let y2 = this.y + this.radius.outer * Math.sin(angle) line.setAttribute("d", `M${x1} ${y1} L${x2} ${y2}`) line.setAttribute("stroke", this.gap.color) line.setAttribute("stroke-width", this.gap.size) this.svg.appendChild(line) }
К сожалению, чтобы корректно перекрывать соединения сегментов, необходимо сначала добавить все окружности и только затем уже линии. Зато вот какая красота у нас получилась:
Шаг 4. Добавляем итоговое значение
Остался последний и, пожалуй, самый простой шаг. Нам нужно разместить текстовый элемент в центре диаграммы. Сделаем это с помощью метода addLabel
:
plot(data) { ... for (offset of offsets) this.addDivider(offset) this.addLabel(total) } addLabel(total) { let label = document.createElementNS("http://www.w3.org/2000/svg", "text") label.textContent = total label.setAttribute("x", this.x) label.setAttribute("y", this.y) label.setAttribute("dominant-baseline", "central") label.setAttribute("text-anchor", "middle") label.setAttribute("fill", this.label.color) label.setAttribute("font-size", this.label.size) label.setAttribute("font-weight", "bold") this.svg.appendChild(label) }
Вот что у нас получилось в итоге:
Немного модифицировав конфигурацию, можно с лёгкостью получить обычную круговую диаграмму:
config = { ..., radius: {outer: 100, inner: 0}, gap: {size: 1, color: "#ffffff"}, // отступ между сегментами startAngle: -90, // откуда начинается диаграмма (в градусах) label: {size: 60, color: "transparent"} // параметры итогового значения, ... }
Итоговый код
В итоге, написав менее 90 строк кода, мы получили класс на чистом JS без каких-либо внешних зависимостей, да и к тому же имея полный контроль над происходящим. Всё работает прозрачно: мы сами управляем отрисовкой, цветами, размерами и логикой расчёта сегментов.
Такая реализация легко адаптируется под любые нужды – от аналитических дашбордов до симпатичных визуализаций для личных проектов или презентаций. Хотите добавить анимацию? Легенду сбоку? Вывод процентов? Всё в ваших руках!
Надеюсь, статья оказалась полезной, а код – понятным и вдохновляющим. Если вы до этого избегали SVG и считали его слишком «сложным» – теперь, возможно, он станет вашим новым визуальным инструментом.
Ну и наконец… Кто бы мог подумать, что 2πR
и тригонометрия действительно пригодятся в жизни, и даже помогут построить красивые диаграммы!
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Пончиковая диаграмма</title> </head> <body> <svg width="210" height="210" id="chart"></svg> <script src="chart.js"></script> <script> const config = { svg: document.getElementById("chart"), radius: {outer: 100, inner: 60}, gap: {size: 3, color: "#ffffff"}, // отступ между сегментами startAngle: -90, // откуда начинается диаграмма (в градусах) label: {size: 60, color: "#333"} // параметры итогового значения } const data = [ {value: 180, color: "#f39c12"}, {value: 95, color: "#2ecc71"}, {value: 150, color: "#e74c3c"}, {value: 75, color: "#3498db"} ] const chart = new Chart(config) chart.plot(data) </script> </body> </html>
class Chart { constructor(config) { this.svg = config.svg this.radius = { outer: config.radius.outer, inner: config.radius.inner ?? 0 } this.gap = {size: config.gap.size ?? 1, color: config.gap.color ?? "#ffffff"} this.startAngle = config.startAngle ?? 0 this.label = config.label // размещать окружности будем по центру svg this.x = this.svg.clientWidth / 2 this.y = this.svg.clientHeight / 2 // обводка рисуется в обе стороны, так что нам нужен средний радиус this.radius.middle = (this.radius.inner + this.radius.outer) / 2 this.strokeWidth = this.radius.outer - this.radius.inner this.length = 2 * Math.PI * this.radius.middle } plot(data) { let total = data.reduce((sum, item) => sum + item.value, 0) // считаем суммарное значение let offset = this.startAngle / 360 let offsets = [offset] for (let item of data) { let value = item.value / total this.addCircle(item.color, value, offset) offset += value offsets.push(offset) } for (offset of offsets) this.addDivider(offset) this.addLabel(total) } addCircle(color, value, offset) { let circle = document.createElementNS("http://www.w3.org/2000/svg", "circle") circle.setAttribute("cx", this.x) circle.setAttribute("cy", this.y) circle.setAttribute("r", this.radius.middle) circle.setAttribute("stroke", color) circle.setAttribute("stroke-width", this.strokeWidth) circle.setAttribute("stroke-dasharray", [value * this.length, (1 - value) * this.length]) circle.setAttribute("stroke-dashoffset", -offset * this.length) circle.setAttribute("fill", "none") this.svg.appendChild(circle) } addDivider(offset) { let line = document.createElementNS("http://www.w3.org/2000/svg", "path") let angle = 2 * Math.PI * offset let x1 = this.x + this.radius.inner * Math.cos(angle) let y1 = this.y + this.radius.inner * Math.sin(angle) let x2 = this.x + this.radius.outer * Math.cos(angle) let y2 = this.y + this.radius.outer * Math.sin(angle) line.setAttribute("d", `M${x1} ${y1} L${x2} ${y2}`) line.setAttribute("stroke", this.gap.color) line.setAttribute("stroke-width", this.gap.size) this.svg.appendChild(line) } addLabel(total) { let label = document.createElementNS("http://www.w3.org/2000/svg", "text") label.textContent = total label.setAttribute("x", this.x) label.setAttribute("y", this.y) label.setAttribute("dominant-baseline", "central") label.setAttribute("text-anchor", "middle") label.setAttribute("fill", this.label.color) label.setAttribute("font-size", this.label.size) label.setAttribute("font-weight", "bold") this.svg.appendChild(label) } }