Как создать пончиковую диаграмму на чистом Javascript и SVG

web development, javascript, svg
Как создать пончиковую диаграмму на чистом Javascript и SVG

Круговые и пончиковые диаграммы – отличный способ визуализировать доли и пропорции. Их можно встретить в инфографике, дашбордах и самых разных отчётах. И хотя готовых библиотек для визуализаций сейчас предостаточно, иногда хочется сделать что-то своё – простое, лёгкое, понятное и без зависимости от фреймворков.

В этой статье мы пошагово создадим пончиковую диаграмму (donut chart) с помощью чистого JavaScript и SVG, без использования внешних библиотек. Мы разберём:

  • как работает stroke-dasharray и stroke-dashoffset,
  • как вычислить длину дуги сегмента,
  • как задать отступы между секторами.

И, конечно же, по ходу дела вспомним школьную формулы вроде длины окружности и даже немного тригонометрии! Да, вот она – та редкая ситуация, когда математика действительно пригодилась в жизни!

Что у нас получится в итоге

Прежде чем углубляться в код, давайте посмотрим, что мы собираемся построить:

  • красивая пончиковая диаграмма с разноцветными сегментами;
  • настраиваемый внешний и внутренний радиус;
  • отступы между секторами;
  • суммарное значение внутри диаграммы.
500

И всё это – с помощью всего одного класса Chart. Вот пример того, как будет выглядеть использование этого класса:

Пример использования класса 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:

Файл 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:

Заготовка chart.js
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, чтобы размещать в них окружности. Чтобы не перерасчитывать одни и те же значения многократно, дополним наш конструктор:

    
Обновлённый конструктор класса 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 }

Для добавления окружностей создадим метод 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. Остаётся понять, как найти угол. С углом на самом деле тоже всё просто: это всего лишь смещение, умноженное на .

Соединим полученные знания:

Добавление отступов
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)
}

Вот что у нас получилось в итоге:

500

Немного модифицировав конфигурацию, можно с лёгкостью получить обычную круговую диаграмму:

config = {
    ...,

    radius: {outer: 100, inner: 0},
    gap: {size: 1, color: "#ffffff"}, // отступ между сегментами
    startAngle: -90, // откуда начинается диаграмма (в градусах)
    label: {size: 60, color: "transparent"} // параметры итогового значения,
    ...
}
500

Итоговый код

В итоге, написав менее 90 строк кода, мы получили класс на чистом JS без каких-либо внешних зависимостей, да и к тому же имея полный контроль над происходящим. Всё работает прозрачно: мы сами управляем отрисовкой, цветами, размерами и логикой расчёта сегментов.

Такая реализация легко адаптируется под любые нужды – от аналитических дашбордов до симпатичных визуализаций для личных проектов или презентаций. Хотите добавить анимацию? Легенду сбоку? Вывод процентов? Всё в ваших руках!

Надеюсь, статья оказалась полезной, а код – понятным и вдохновляющим. Если вы до этого избегали SVG и считали его слишком «сложным» – теперь, возможно, он станет вашим новым визуальным инструментом.

Ну и наконец… Кто бы мог подумать, что 2πR и тригонометрия действительно пригодятся в жизни, и даже помогут построить красивые диаграммы!

Итоговый файл 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>
        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>
Итоговый файл chart.js
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)
    }
}