Как создать линейную диаграмму на чистом Javascript и SVG

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

Линейная диаграмма – это один из самых наглядных и популярных способов отобразить изменение числовых значений с течением времени. Её часто используют для визуализации трендов, показателей роста, температуры, курса валют и любых других данных, где важна последовательность и динамика.

В этой статье вы узнаете, как с нуля реализовать линейную диаграмму на чистом JavaScript и SVG. Как и в прошлый раз, вся реализация будет выполнена внутри единственного класса LinearChart, без сторонних библиотек, только нативный код и простая логика.

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

6.2январь20244.5февраль20247.1март20247апрель20244.1май20245.3июнь20246.1июль20245.9август20249.3сентябрь20248.5октябрь202410.1ноябрь20248.2декабрь2024

Вот пример, как будет выглядеть использование нашего класса:

Пример использования класса LinearChart
const config = {
    svg: document.getElementById("linear-chart"),
    padding: {bottom: 25, top: 25},
    line: {
        color: "#fe7a81", // цвет линии
        background: "#ffddd7" // цвет области
    },
    marker: {
        radius: 5,
        color: "#fe7a81", // цвет контура маркера
        background: "#ffffff", // цвет заливки маркера
        width: 60, // ширина для одного значения
        height: 280 // максимальная высота для значения 
    },
    value: {size: 11, color: "#fe7a81"}, // параметры подписи значения
    label: {size: 10, color: "#888"} // параметры подписи метки
}

const data = [
    {value: 6.2, label: "январь\n2024"},
    {value: 4.5, label: "февраль\n2024"},
    {value: 7.1, label: "март\n2024"},
    {value: 7, label: "апрель\n2024"},
    {value: 4.1, label: "май\n2024"},
    {value: 5.3, label: "июнь\n2024"},
    {value: 6.1, label: "июль\n2024"},
    {value: 5.9, label: "август\n2024"},
    {value: 9.3, label: "сентябрь\n2024"},
    {value: 8.5, label: "октябрь\n2024"},
    {value: 10.1, label: "ноябрь\n2024"},
    {value: 8.2, label: "декабрь\n2024"}
]

const linearChart = new LinearChart(config)
linearChart.plot(data)

Теперь давайте создадим всё это по шагам – от заготовки HTML и JS до красивой и понятной линейной диаграммы.

Шаг 0. Подготовка

Начнём с создания двух файлов: linear_chart.html и linear_chart.js. В первом будет наша HTML-разметка и SVG-элемент, во втором – сам класс LinearChart, который мы будем постепенно наполнять.

Вот базовая заготовка linear_chart.html:

Файл linear_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 id="linear-chart"></svg>

    <script src="linear_chart.js"></script>
    <script>
        // создание и управление диаграммой
    </script>
</body>
</html>

Теперь создадим файл linear_chart.js и опишем каркас класса:

Файл linear_chart.js
constructor(config) {
    this.svg = config.svg
    this.padding = {
        top: config.padding?.top ?? 15,
        bottom: config.padding?.bottom ?? 25
    }

    this.line = {
        color: config.line?.color ?? "#9cc2ff",
        background: config.line?.background ?? "#ecf4ff"
    }

    this.marker = {
        radius: config.marker?.radius ?? 5,
        color: config.marker?.color ?? "#9cc2ff",
        background: config.marker?.background ?? "#ffffff",
        width: config.marker?.width ?? 50,
        height: config.marker?.height ?? 300
    }

    this.value = {
        size: config.value.size ?? 10,
        color: config.value.color ?? "#888"
    }

    this.label = {
        size: config.label.size ?? 10,
        color: config.label.color ?? "#888"
    }
}

Пока всё просто – мы подготовили класс и сохранили в нём нужные параметры из конфига.

Шаг 1. Определяемся с размерами

Давайте посчитаем, какого размера должен быть наш svg элемент:

  • высота складывается из величин оступов (padding.top и padding.bottom), а также высоты маркера;
  • для вычисления ширины достаточно просто умножить количество элементов в data на ширину маркера.

Вычислим размеры и зададим их нашей диаграмме в методе plot:

Задаём размеры диаграммы
plot(data) {
    const width = data.length * this.marker.width
    const height = this.padding.top + this.marker.height + this.padding.bottom

    this.svg.style.width = `${width}px`
    this.svg.style.height = `${height}px`
}

Также для отрисовки нам нужно знать, в каких диапазонах изменяются отрисовываемые значения и если в них нет перехода через 0, то нам нужно явно его добавить:

Вычисляем диапазон изменения значений
const values = data.map(item => item.value)
const min = Math.min(...values, 0)
const max = Math.max(...values, 0)

Шаг 2. Вычисление координат

Чтобы отрисовать диаграмму, нужно правильно перевести значения данных в координаты на SVG. Для этого реализуем вспомогательную функцию map, которая позволяет "перемасштабировать" значение из одного диапазона в другой. Она понадобится нам практически на каждом этапе:

Файл linear_chart.js
map(x, xmin, xmax, min, max) {
    return (x - xmin) * (max - min) / (xmax - xmin) + min
}

Теперь, чтобы узнать, в какой координате должна быть точка со значением value достаточно просто вызвать функцию map:

Вычисление координаты y для некоторого значения value
const y = map(value, min, max, height - this.padding.bottom, this.padding.top)

Обратите внимание, в каком порядке мы передали height - this.padding.bottom и this.padding.top в качестве диапазона изменений координаты. Это связано с тем, что в svg координатная вертикальная ось направлена вниз, а не вверх, а потому большие значения value должны иметь меньшую координату на svg.

Рисуем линии

Чтобы построить кривую – будь то ломаная или сглаженная – в SVG используется элемент <path>. Помимо привычных атрибутов fill, stroke и stroke-width, с которыми вы уже сталкивались в статье про пончиковую диаграмму, у него есть особый атрибут d, содержащий команды для рисования.

Нас интересуют две команды:

  • M x y – перемещает "перо" (курсор) в точку с координатами (x, y) без рисования;
  • L x y – рисует прямую линию от текущей позиции к точке (x, y) и перемещает перо в неё.

В рамках нашей диаграммы мы сначала переместим перо в первую точку (M), а затем последовательно нарисуем линии ко всем остальным (L). Таким образом, получится основная линия графика.

Кроме неё, мы также хотим визуализировать закрашенную область под графиком – от первой до последней точки, с "замыканием" к нулевому уровню по оси Y. Эту зону мы будем отображать как отдельный путь, которому зададим заливку.

Чтобы всё это реализовать, добавим в класс метод addPaths, который создаст и добавит в SVG две кривые: одну для основной линии (line), другую – для закрашенной области (area). Вызовем этот метод внутри plot.

Добавляем элементы для отрисовки линий
plot(data) {
    ...

    const paths = this.addPaths()
}

addPaths() {
    // добавляем path для закрашенной области
    const area = document.createElementNS("http://www.w3.org/2000/svg", "path")
    area.setAttribute("fill", this.line.background)
    this.svg.appendChild(area)

    // добавляем path для основной линии
    const line = document.createElementNS("http://www.w3.org/2000/svg", "path")
    line.setAttribute("fill", "none")
    line.setAttribute("stroke", this.line.color)
    line.setAttribute("stroke-width", this.marker.radius / 2)
    this.svg.appendChild(line)

    return {area, line}
}

Прежде чем мы сможем построить линии, нам необходимо вычислить координаты всех точек. Для этого создадим массив points, в который будем сохранять положения каждой точки на диаграмме. Этот массив позже будет использоваться как для отрисовки линии, так и для закрашенной области.

Формируем массив с координатами точек
plot(data) {
    ...

    const paths = this.addPaths()
    const points = []

    for (let i = 0; i < data.length; i++) {
        const x = this.map(i, 0, data.length - 1, this.marker.width / 2, width - this.marker.width / 2)
        const y = this.map(data[i].value, min, max, height - this.padding.bottom, this.padding.top)

        points.push({x: x, y: y})
    }
}

Когда массив координат готов, мы можем перейти к построению кривых – для этого нужно задать атрибут d у созданных ранее элементов path. Вынесем эту логику в отдельный метод updatePaths:

Отрисовываем линии
plot(data) {
    ...

    this.updatePaths(paths, points, min, max, height)
}

updatePaths(paths, points, min, max, height) {
    const y0 = this.map(0, min, max, height - this.padding.bottom, this.padding.top) // вычисляем координату нулевого значения

    paths.line.setAttribute("d", this.points2path(points))
    paths.area.setAttribute("d", this.points2path([{x: points[0].x, y: y0}, ...points, {x: points[points.length - 1].x, y: y0}]))
}

Если теперь запустить пример, приведённый в начале статьи, мы увидим нечто, уже напоминающее линейную диаграмму:

Однако пока она выглядит довольно пусто – без подписей она малоинформативна.

Добавляем подписи

Пора сделать визуализацию более выразительной. Как и в случае со столбчатой диаграммой, мы создадим универсальный метод addLabel, позволяющий добавлять текст с заданным выравниванием. Он также поддерживает многострочные надписи, если текст содержит символы перевода строки.

Метод добавления произвольной подписи
addLabel(x, y, text, format, anchor, baseline) {
    for (const line of text.split("\n")) {
        const label = document.createElementNS("http://www.w3.org/2000/svg", "text")

        label.textContent = line
        label.setAttribute("x", x)
        label.setAttribute("y", y)
        label.setAttribute("dominant-baseline", baseline)
        label.setAttribute("text-anchor", anchor)
        label.setAttribute("fill", format.color)
        label.setAttribute("font-size", format.size)

        this.svg.appendChild(label)

        y += label.getBBox().height
    }
}

Теперь, внутри цикла, где вычисляются координаты точек, добавим подписи к меткам и значениям:

Добавляем подписи
for (let i = 0; i < data.length; i++) {
    const x = this.map(i, 0, data.length - 1, this.marker.width / 2, width - this.marker.width / 2)
    const y = this.map(data[i].value, min, max, height - this.padding.bottom, this.padding.top)

    this.addLabel(x, y - 1, `${data[i].value}`, this.value, "middle", "text-after-edge")
    this.addLabel(x, height - this.padding.bottom, data[i].label, this.label, "middle", "text-before-edge")

    points.push({x: x, y: y})
}

На этом этапе диаграмма уже выглядит значительно лучше – появились осмысленные подписи. Но всё ещё есть пространство для улучшений.

6.2январь20244.5февраль20247.1март20247апрель20244.1май20245.3июнь20246.1июль20245.9август20249.3сентябрь20248.5октябрь202410.1ноябрь20248.2декабрь2024

Добавляем маркеры

Чтобы сделать диаграмму визуально выразительнее, добавим маркеры – небольшие окружности в точках данных. Для этого создадим метод addMarker и вызовем его внутри уже знакомого нам цикла, где вычисляются координаты точек:

Добавляем маркеры
plot() {
    ...

    for (let i = 0; i < data.length; i++) {
        ...
        this.addLabel(x, y - this.marker.radius - 1, `${data[i].value}`, this.value, "middle", "text-after-edge")
        this.addLabel(x, height - this.padding.bottom, data[i].label, this.label, "middle", "text-before-edge")
        this.addMarker(x, y)
    }
    ...
}

addMarker(x, y) {
    const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle")
    marker.setAttribute("cx", x)
    marker.setAttribute("cy", y)
    marker.setAttribute("r", this.marker.radius)
    marker.setAttribute("fill", this.marker.background)
    marker.setAttribute("stroke", this.marker.color)
    marker.setAttribute("stroke-width", this.marker.radius / 2)

    this.svg.appendChild(marker)
}

После этой небольшой доработки диаграмма выглядит значительно лучше:

6.2январь20244.5февраль20247.1март20247апрель20244.1май20245.3июнь20246.1июль20245.9август20249.3сентябрь20248.5октябрь202410.1ноябрь20248.2декабрь2024

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

Сдвигаем первую и последнюю точки к краям

Введём переменную offset, которая будет равна нулю для всех точек, кроме первой и последней. Первую точку сдвинем немного влево, последнюю – вправо. При этом учтём не только половину ширины маркера, но и его радиус с обводкой, чтобы добиться аккуратного выравнивания.

Сдвигаем границы точек
const offset = (this.marker.width / 2 - this.marker.radius * 1.5) * (i == 0 ? -1 : i == data.length - 1 ? 1 : 0)

this.addLabel(x + offset, y - this.marker.radius - 1, `${data[i].value}`, this.value, "middle", "text-after-edge")
this.addLabel(x, height - this.padding.bottom, data[i].label, this.label, "middle", "text-before-edge")
this.addMarker(x + offset, y)

Теперь диаграмма выглядит ещё естественнее:

6.2январь20244.5февраль20247.1март20247апрель20244.1май20245.3июнь20246.1июль20245.9август20249.3сентябрь20248.5октябрь202410.1ноябрь20248.2декабрь2024

Казалось бы, всё готово – но есть один нюанс: если текст у первого или последнего значения окажется шире маркера, он может выйти за пределы диаграммы и обрезаться. Чтобы этого избежать, изменим выравнивание подписей: первая будет выровнена по левому краю, последняя – по правому. Все остальные останутся выровненными по центру:

Выравниваем подписи значений
const offset = (this.marker.width / 2 - this.marker.radius * 1.5) * (i == 0 ? -1 : i == data.length - 1 ? 1 : 0)
const anchor = i == 0 ? "start" : i == data.length - 1 ? "end" : "middle"

this.addLabel(x + offset, y - this.marker.radius - 1, `${data[i].value}`, this.value, anchor, "text-after-edge")

Визуально диаграмма почти не изменилась, но теперь мы можем быть уверены, что любые значения будут отображаться корректно, независимо от их длины:

6.2январь20244.5февраль20247.1март20247апрель20244.1май20245.3июнь20246.1июль20245.9август20249.3сентябрь20248.5октябрь202410.1ноябрь20248.2декабрь2024

На этом можно было бы завершить, но есть ещё один штрих: линия графика выглядит слишком ломаной. Давайте сделаем её плавнее, добавив сглаживание!

Сглаживаем прямые

В красивых диаграммах редко встречаются резкие переходы на кривых, поэтому мы тоже не останемся в стороне и добавим сглаживание. Для этого между реальными значениями добавим промежуточные точки с помощью интерполяции.

Мы будем использовать метод обратных взвешенных расстояний. Он работает довольно просто: для каждой точки x, в которой нужно найти значение, метод учитывает все известные точки и вычисляет вклад каждой из них. Этот вклад обратно пропорционален расстоянию: чем ближе точка, тем сильнее она влияет на итоговый результат.

Мы используем взвешенное среднее значений y, где веса пропорциональны обратному квадрату расстояния от нужной точки до каждой известной. Если точка совпадает с одной из известных, мы просто берём её значение:

Интерполяция методом обратных взвешенных расстояний
interpolate(x, points) {
    let result = 0
    let sum = 0

    for (let i = 0; i < points.length; i++) {
        const distance = Math.abs(x - points[i].x)

        if (distance === 0)
            return {x: x, y: points[i].y}

        const weight = 1 / Math.pow(distance, 2)
        result += points[i].y * weight
        sum += weight
    }

    return {x: x, y: result / sum}
}

Теперь дополним массив координат новыми точками с помощью метода smoothPoints:

Получение сглаженных точек
smoothPoints(points, smoothCount = 20) {
    const smoothedPoints = [points[0]]

    for (let i = 1; i < points.length; i++) {
        for (let j = 0; j < smoothCount; j++)
            smoothedPoints.push(this.interpolate(this.map(j, -1, smoothCount, points[i - 1].x, points[i].x), points))

        smoothedPoints.push(points[i])
    }

    return smoothedPoints
}

В качестве входного диапазона для функции map мы передаём значения [-1, smoothCount], так как добавлять новые точки мы хотим только внутри диапазона [0, smoothCount - 1].

После этого остаётся только вызвать метод сглаживания внутри updatePaths, чтобы добавить новые точки в диаграмму:

Сглаживаем точки
updatePaths(paths, points, min, max, height) {
    const y0 = this.map(0, min, max, height - this.padding.bottom, this.padding.top)
    const smoothedPoints = this.smoothPoints(points)

    paths.line.setAttribute("d", this.points2path(smoothedPoints))
    paths.area.setAttribute("d", this.points2path([{x: points[0].x, y: y0}, ...smoothedPoints, {x: points[points.length - 1].x, y: y0}]))
}

И вот теперь наша диаграмма выглядит именно так, как и должна:

6.2январь20244.5февраль20247.1март20247апрель20244.1май20245.3июнь20246.1июль20245.9август20249.3сентябрь20248.5октябрь202410.1ноябрь20248.2декабрь2024

Итого

В данной статье мы шаг за шагом реализовали линейную диаграмму с использованием чистого JavaScript и SVG. Мы подробно рассмотрели процесс создания диаграммы, начиная с построения линий и добавления подписей, до реализации маркеров и добавления сглаживания кривых для улучшения визуального восприятия:

  • Построение линии и области: используя элемент path и команды M и L, мы научились рисовать как основную линию, так и дополнительные области, создавая полноценную диаграмму.
  • Подписи и маркеры: мы добавили текстовые подписи и маркеры для точек на диаграмме, что значительно повысило информативность и удобство восприятия.
  • Сглаживание: сглаживание кривых с помощью метода обратных взвешенных расстояний позволило сделать диаграмму более плавной и визуально приятной.

В итоге, всего за 150 строк кода (30 из которых просто сохранение конфигурации) мы создали динамическую и настраиваемую линейную диаграмму, которая может быть легко адаптирована под разные нужды, а её внешний вид и функциональность значительно улучшены за счёт нескольких небольших, но важных изменений.

Итоговый файл linear_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 id="linear-chart"></svg>

    <script src="linear_chart.js"></script>
    <script>
        const config = {
            svg: document.getElementById("linear-chart"),
            padding: {bottom: 25, top: 25},
            line: {
                color: "#fe7a81",
                background: "#ffddd7"
            },
            marker: {
                radius: 5,
                color: "#fe7a81",
                background: "#ffffff",
                width: 60,
                height: 280
            },
            value: {size: 11, color: "#fe7a81"},
            label: {size: 10, color: "#888"}
        }

        const data = [
            {value: 6.2, label: "январь\n2024"},
            {value: 4.5, label: "февраль\n2024"},
            {value: 7.1, label: "март\n2024"},
            {value: 7, label: "апрель\n2024"},
            {value: 4.1, label: "май\n2024"},
            {value: 5.3, label: "июнь\n2024"},
            {value: 6.1, label: "июль\n2024"},
            {value: 5.9, label: "август\n2024"},
            {value: 9.3, label: "сентябрь\n2024"},
            {value: 8.5, label: "октябрь\n2024"},
            {value: 10.1, label: "ноябрь\n2024"},
            {value: 8.2, label: "декабрь\n2024"}
        ]

        const linearChart = new LinearChart(config)
        linearChart.plot(data)
    </script>
</body>
</html>
Итоговый файл linear_chart.js
class LinearChart {
    constructor(config) {
        this.svg = config.svg
        this.padding = {
            top: config.padding?.top ?? 15,
            bottom: config.padding?.bottom ?? 25
        }

        this.line = {
            color: config.line?.color ?? "#9cc2ff",
            background: config.line?.background ?? "#ecf4ff"
        }

        this.marker = {
            radius: config.marker?.radius ?? 5,
            color: config.marker?.color ?? "#9cc2ff",
            background: config.marker?.background ?? "#ffffff",
            width: config.marker?.width ?? 50,
            height: config.marker?.height ?? 300
        }

        this.value = {
            size: config.value.size ?? 10,
            color: config.value.color ?? "#888"
        }

        this.label = {
            size: config.label.size ?? 10,
            color: config.label.color ?? "#888"
        }
    }

    plot(data) {
        const width = data.length * this.marker.width
        const height = this.padding.top + this.marker.height + this.padding.bottom

        this.svg.style.width = `${width}px`
        this.svg.style.height = `${height}px`

        const values = data.map(item => item.value)
        const min = Math.min(...values, 0)
        const max = Math.max(...values, 0)

        const points = []
        const paths = this.addPaths(points, this.map(0, min, max, height - this.padding.bottom, this.padding.top))

        for (let i = 0; i < data.length; i++) {
            const x = this.map(i, 0, data.length - 1, this.marker.width / 2, width - this.marker.width / 2)
            const y = this.map(data[i].value, min, max, height - this.padding.bottom, this.padding.top)
            const offset = (this.marker.width / 2 - this.marker.radius * 1.5) * (i == 0 ? -1 : i == data.length - 1 ? 1 : 0)
            const anchor = i == 0 ? "start" : i == data.length - 1 ? "end" : "middle"

            this.addLabel(x + offset, y - this.marker.radius - 1, `${data[i].value}`, this.value, anchor, "text-after-edge")
            this.addLabel(x, height - this.padding.bottom, data[i].label, this.label, "middle", "text-before-edge")
            this.addMarker(x + offset, y)

            points.push({x: x + offset, y: y})
        }

        this.updatePaths(paths, points, min, max, height)
    }

    map(x, xmin, xmax, min, max) {
        return (x - xmin) * (max - min) / (xmax - xmin) + min
    }

    addLabel(x, y, text, format, anchor, baseline) {
        for (const line of text.split("\n")) {
            const label = document.createElementNS("http://www.w3.org/2000/svg", "text")

            label.textContent = line
            label.setAttribute("x", x)
            label.setAttribute("y", y)
            label.setAttribute("dominant-baseline", baseline)
            label.setAttribute("text-anchor", anchor)
            label.setAttribute("fill", format.color)
            label.setAttribute("font-size", format.size)

            this.svg.appendChild(label)

            y += label.getBBox().height
        }
    }

    addPaths() {
        const area = document.createElementNS("http://www.w3.org/2000/svg", "path")
        area.setAttribute("fill", this.line.background)

        const line = document.createElementNS("http://www.w3.org/2000/svg", "path")
        line.setAttribute("fill", "none")
        line.setAttribute("stroke", this.line.color)
        line.setAttribute("stroke-width", this.marker.radius / 2)

        this.svg.appendChild(area)
        this.svg.appendChild(line)
        return {area, line}
    }

    addMarker(x, y) {
        const marker = document.createElementNS("http://www.w3.org/2000/svg", "circle")
        marker.setAttribute("cx", x)
        marker.setAttribute("cy", y)
        marker.setAttribute("r", this.marker.radius)
        marker.setAttribute("fill", this.marker.background)
        marker.setAttribute("stroke", this.marker.color)
        marker.setAttribute("stroke-width", this.marker.radius / 2)

        this.svg.appendChild(marker)
    }

    points2path(points) {
        return points.map((point, i) => `${i == 0 ? "M" : "L"}${point.x} ${point.y}`).join("")
    }

    interpolate(x, points) {
        let result = 0
        let sum = 0

        for (let i = 0; i < points.length; i++) {
            const distance = Math.abs(x - points[i].x)

            if (distance === 0)
                return {x: x, y: points[i].y}

            const weight = 1 / Math.pow(distance, 2)
            result += points[i].y * weight
            sum += weight
        }

        return {x: x, y: result / sum}
    }

    smoothPoints(points, smoothCount = 20) {
        const smoothedPoints = [points[0]]

        for (let i = 1; i < points.length; i++) {
            for (let j = 0; j < smoothCount; j++)
                smoothedPoints.push(this.interpolate(this.map(j, -1, smoothCount, points[i - 1].x, points[i].x), points))

            smoothedPoints.push(points[i])
        }

        return smoothedPoints
    }

    updatePaths(paths, points, min, max, height) {
        const y0 = this.map(0, min, max, height - this.padding.bottom, this.padding.top)
        const smoothedPoints = this.smoothPoints(points)

        paths.line.setAttribute("d", this.points2path(smoothedPoints))
        paths.area.setAttribute("d", this.points2path([{x: points[0].x, y: y0}, ...smoothedPoints, {x: points[points.length - 1].x, y: y0}]))
    }
}