Лепестковая диаграмма (radar chart)

Структура данных для радиальной диаграммы

Лепестковая диаграмма опирается на многомерные данные, где каждая ось соответствует отдельной метрике. Типичная структура включает набор объектов, каждый из которых описывает один «контур» диаграммы.

const data = [
  {
    name: "Модель A",
    values: [
      { axis: "Скорость", value: 80 },
      { axis: "Надёжность", value: 90 },
      { axis: "Удобство", value: 70 },
      { axis: "Цена", value: 60 },
      { axis: "Функциональность", value: 85 }
    ]
  }
];

Каждый объект values представляет собой пару:

  • axis — название измерения
  • value — числовое значение

Для корректного отображения данные приводятся к единой шкале.


Подготовка шкал и нормализация

Лепестковая диаграмма требует преобразования значений в радиальную систему координат. Для этого используется масштабирование через d3.scaleLinear.

const maxValue = 100;

const radiusScale = d3.scaleLinear()
  .domain([0, maxValue])
  .range([0, radius]);

Где radius — радиус окружности, в пределах которой строится диаграмма.

Нормализация значений обеспечивает сопоставимость разных метрик.


Распределение осей по окружности

Количество осей определяется числом параметров. Каждая ось равномерно распределяется по углу окружности.

const totalAxes = data[0].values.length;

const angleSlice = (Math.PI * 2) / totalAxes;

Угол каждой оси вычисляется как:

  • 0 ось: 0
  • 1 ось: angleSlice
  • 2 ось: 2 * angleSlice
  • и далее по кругу

Перевод в декартовы координаты

Для отображения точек используется преобразование полярных координат:

function getCoordinates(angle, value) {
  const r = radiusScale(value);
  return {
    x: Math.cos(angle) * r,
    y: Math.sin(angle) * r
  };
}

Важно учитывать центр диаграммы:

const centerX = width / 2;
const centerY = height / 2;

И финальное позиционирование:

x: centerX + Math.cos(angle) * r
y: centerY + Math.sin(angle) * r

Отрисовка осей

Каждая ось представляет линию от центра к краю окружности.

const axisGroup = svg.append("g")
  .attr("class", "axes");

axisGroup.selectAll("line")
  .data(data[0].values)
  .enter()
  .append("line")
  .attr("x1", centerX)
  .attr("y1", centerY)
  .attr("x2", (d, i) =>
    centerX + Math.cos(angleSlice * i) * radius
  )
  .attr("y2", (d, i) =>
    centerY + Math.sin(angleSlice * i) * radius
  )
  .attr("stroke", "#999");

Каждая ось фиксирует направление измерения.


Подписи осей

Текстовые метки размещаются на концах осей с небольшим смещением наружу.

axisGroup.selectAll(".axis-label")
  .data(data[0].values)
  .enter()
  .append("text")
  .attr("x", (d, i) =>
    centerX + Math.cos(angleSlice * i) * (radius + 10)
  )
  .attr("y", (d, i) =>
    centerY + Math.sin(angleSlice * i) * (radius + 10)
  )
  .text(d => d.axis)
  .style("font-size", "12px")
  .attr("text-anchor", "middle");

Для улучшения читаемости часто применяют условное выравнивание текста в зависимости от угла.


Построение многоугольника значений

Основная фигура диаграммы формируется замкнутым многоугольником. Для этого используется генератор линий d3.lineRadial или ручное построение пути.

Вариант через lineRadial

const radarLine = d3.lineRadial()
  .radius(d => radiusScale(d.value))
  .angle((d, i) => i * angleSlice);

Использование:

svg.append("path")
  .datum(data[0].values)
  .attr("d", radarLine)
  .attr("transform", `translate(${centerX},${centerY})`)
  .attr("fill", "rgba(0, 150, 255, 0.3)")
  .attr("stroke", "#0077cc");

Вариант ручной сборки пути

При необходимости более точного контроля используется ручное формирование координат.

const line = d3.line()
  .x((d, i) =>
    centerX + Math.cos(angleSlice * i) * radiusScale(d.value)
  )
  .y((d, i) =>
    centerY + Math.sin(angleSlice * i) * radiusScale(d.value)
  )
  .curve(d3.curveLinearClosed);

Добавление точек на вершинах

Для повышения читаемости диаграммы добавляются маркеры значений.

svg.selectAll(".radar-point")
  .data(data[0].values)
  .enter()
  .append("circle")
  .attr("cx", (d, i) =>
    centerX + Math.cos(angleSlice * i) * radiusScale(d.value)
  )
  .attr("cy", (d, i) =>
    centerY + Math.sin(angleSlice * i) * radiusScale(d.value)
  )
  .attr("r", 3)
  .attr("fill", "#0077cc");

Многослойная визуализация

Для сравнения нескольких наборов данных используется наложение контуров.

const color = d3.scaleOrdinal(d3.schemeCategory10);

svg.selectAll(".radar-wrapper")
  .data(data)
  .enter()
  .append("path")
  .attr("d", d => radarLine(d.values))
  .attr("transform", `translate(${centerX},${centerY})`)
  .attr("fill", d => color(d.name))
  .attr("opacity", 0.2)
  .attr("stroke", d => color(d.name));

Каждый слой представляет отдельный объект сравнения.


Сетка (grid) диаграммы

Для ориентации создаются концентрические многоугольники или окружности.

const levels = 5;

const gridGroup = svg.append("g")
  .attr("class", "grid");

for (let level = 1; level <= levels; level++) {
  const r = radius / levels * level;

  gridGroup.append("circle")
    .attr("cx", centerX)
    .attr("cy", centerY)
    .attr("r", r)
    .attr("fill", "none")
    .attr("stroke", "#ddd");
}

Сетка обеспечивает визуальную оценку относительных значений.


Обработка угловых смещений текста

Для улучшения читаемости применяется корректировка выравнивания текста:

function getTextAnchor(angle) {
  if (angle === 0 || angle === Math.PI * 2) return "middle";
  if (angle < Math.PI) return "start";
  return "end";
}

Анимация построения диаграммы

Плавное появление линий повышает восприятие структуры данных.

svg.selectAll("path")
  .attr("stroke-dasharray", function() {
    const length = this.getTotalLength();
    return length + " " + length;
  })
  .attr("stroke-dashoffset", function() {
    return this.getTotalLength();
  })
  .transition()
  .duration(1000)
  .attr("stroke-dashoffset", 0);

Интерактивное изменение значений

Изменение данных в реальном времени реализуется через обновление привязанных данных.

function updateRadar(newData) {
  svg.selectAll("path")
    .data(newData)
    .attr("d", d => radarLine(d.values));
}

При изменении структуры требуется пересчёт масштабов и углов.


Обработка различного количества осей

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

angleSlice = (Math.PI * 2) / newData[0].values.length;

Все элементы, зависящие от угла, обновляются синхронно.


Оптимизация рендеринга

При больших наборах данных применяются следующие подходы:

  • минимизация количества DOM-элементов
  • использование path вместо множества линий
  • кэширование вычислений координат
  • отказ от избыточных transition при частых обновлениях

Цветовые схемы и дифференциация слоёв

Для различения серий данных применяется шкала цветов:

const colorScale = d3.scaleOrdinal()
  .domain(data.map(d => d.name))
  .range(["#1f77b4", "#ff7f0e", "#2ca02c"]);

Каждая серия получает собственный стиль заливки и обводки.


Масштабирование и адаптивность

Диаграмма адаптируется под размер контейнера:

const radius = Math.min(width, height) / 2 - margin;

При изменении размеров пересчитываются:

  • координаты центра
  • радиус шкалы
  • позиции осей и текста

Комбинирование с tooltip

Для отображения значений используется всплывающая подсказка.

.on("mouseover", (event, d) => {
  tooltip
    .style("opacity", 1)
    .html(`${d.axis}: ${d.value}`);
})
.on("mousemove", event => {
  tooltip
    .style("left", event.pageX + "px")
    .style("top", event.pageY + "px");
});

Геометрическая устойчивость формы

При большом разбросе значений форма может становиться неинформативной. Для стабилизации применяются:

  • логарифмическое масштабирование
  • ограничение максимума
  • нормализация по процентилям
const scale = d3.scaleLinear()
  .domain([d3.min(values), d3.max(values)])
  .nice();

Согласование углов и симметрии

Равномерность распределения осей обеспечивает визуальный баланс. При нечётных количествах метрик применяется поворот начальной оси для симметрии восприятия.