Линейная диаграмма – это один из самых наглядных и популярных способов отобразить изменение числовых значений с течением времени. Её часто используют для визуализации трендов, показателей роста, температуры, курса валют и любых других данных, где важна последовательность и динамика.
В этой статье вы узнаете, как с нуля реализовать линейную диаграмму на чистом JavaScript и SVG. Как и в прошлый раз, вся реализация будет выполнена внутри единственного класса 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
:
<!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
и опишем каркас класса:
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
, которая позволяет "перемасштабировать" значение из одного диапазона в другой. Она понадобится нам практически на каждом этапе:
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}) }
На этом этапе диаграмма уже выглядит значительно лучше – появились осмысленные подписи. Но всё ещё есть пространство для улучшений.
Добавляем маркеры
Чтобы сделать диаграмму визуально выразительнее, добавим маркеры – небольшие окружности в точках данных. Для этого создадим метод 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) }
После этой небольшой доработки диаграмма выглядит значительно лучше:
Но всё же остаётся ощущение, что чего-то не хватает. Интуитивно хочется, чтобы первая точка начиналась у самой левой границы, а последняя – касалась правой. Исправим это.
Сдвигаем первую и последнюю точки к краям
Введём переменную 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)
Теперь диаграмма выглядит ещё естественнее:
Казалось бы, всё готово – но есть один нюанс: если текст у первого или последнего значения окажется шире маркера, он может выйти за пределы диаграммы и обрезаться. Чтобы этого избежать, изменим выравнивание подписей: первая будет выровнена по левому краю, последняя – по правому. Все остальные останутся выровненными по центру:
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")
Визуально диаграмма почти не изменилась, но теперь мы можем быть уверены, что любые значения будут отображаться корректно, независимо от их длины:
На этом можно было бы завершить, но есть ещё один штрих: линия графика выглядит слишком ломаной. Давайте сделаем её плавнее, добавив сглаживание!
Сглаживаем прямые
В красивых диаграммах редко встречаются резкие переходы на кривых, поэтому мы тоже не останемся в стороне и добавим сглаживание. Для этого между реальными значениями добавим промежуточные точки с помощью интерполяции.
Мы будем использовать метод обратных взвешенных расстояний. Он работает довольно просто: для каждой точки 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}])) }
И вот теперь наша диаграмма выглядит именно так, как и должна:
Итого
В данной статье мы шаг за шагом реализовали линейную диаграмму с использованием чистого JavaScript и SVG. Мы подробно рассмотрели процесс создания диаграммы, начиная с построения линий и добавления подписей, до реализации маркеров и добавления сглаживания кривых для улучшения визуального восприятия:
- Построение линии и области: используя элемент
path
и командыM
иL
, мы научились рисовать как основную линию, так и дополнительные области, создавая полноценную диаграмму. - Подписи и маркеры: мы добавили текстовые подписи и маркеры для точек на диаграмме, что значительно повысило информативность и удобство восприятия.
- Сглаживание: сглаживание кривых с помощью метода обратных взвешенных расстояний позволило сделать диаграмму более плавной и визуально приятной.
В итоге, всего за 150 строк кода (30 из которых просто сохранение конфигурации) мы создали динамическую и настраиваемую линейную диаграмму, которая может быть легко адаптирована под разные нужды, а её внешний вид и функциональность значительно улучшены за счёт нескольких небольших, но важных изменений.
<!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>
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}])) } }