Линейный график: одна и несколько серий

Базовая структура линейного графика

Линейный график в D3.js строится на сочетании нескольких ключевых компонентов: шкал (scales), генератора линии (line generator), осей (axes) и SVG-контейнера. Основная идея заключается в преобразовании массива данных в набор координат, которые затем интерпретируются как путь (path) в SVG.

Типичная структура данных для одной серии:

const data = [
  { x: 0, y: 10 },
  { x: 1, y: 20 },
  { x: 2, y: 15 },
  { x: 3, y: 30 }
];

SVG-контейнер задаёт область визуализации:

const width = 800;
const height = 400;
const margin = { top: 20, right: 30, bottom: 40, left: 50 };

const svg = d3.select("svg")
  .attr("width", width)
  .attr("height", height);

Масштабирование данных (scales)

Шкалы определяют соответствие между значениями данных и пикселями на экране.

const xScale = d3.scaleLinear()
  .domain(d3.extent(data, d => d.x))
  .range([margin.left, width - margin.right]);

const yScale = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.y)])
  .nice()
  .range([height - margin.bottom, margin.top]);

Ключевой момент: ось Y инвертируется, так как координатная система SVG начинается сверху.

Генератор линии

Генератор линии преобразует данные в SVG-путь:

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

Результат работы — строка d, используемая в <path>.

svg.append("path")
  .datum(data)
  .attr("fill", "none")
  .attr("stroke", "steelblue")
  .attr("stroke-width", 2)
  .attr("d", line);

Оси координат

Оси добавляют читаемость и контекст:

const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);

svg.append("g")
  .attr("transform", `translate(0,${height - margin.bottom})`)
  .call(xAxis);

svg.append("g")
  .attr("transform", `translate(${margin.left},0)`)
  .call(yAxis);

Линейный график с одной серией данных

Односерийный график представляет один набор зависимых значений. Основная логика остаётся линейной: данные → шкалы → линия → отрисовка.

Полная сборка:

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

svg.append("path")
  .datum(data)
  .attr("fill", "none")
  .attr("stroke", "steelblue")
  .attr("stroke-width", 2)
  .attr("d", line);

Для улучшения читаемости часто добавляются точки:

svg.selectAll("circle")
  .data(data)
  .enter()
  .append("circle")
  .attr("cx", d => xScale(d.x))
  .attr("cy", d => yScale(d.y))
  .attr("r", 3)
  .attr("fill", "steelblue");

Несколько серий данных

Многосерийный линейный график требует иной структуры данных. Наиболее распространённый формат:

const data = [
  {
    name: "Серия A",
    values: [
      { x: 0, y: 10 },
      { x: 1, y: 20 },
      { x: 2, y: 15 }
    ]
  },
  {
    name: "Серия B",
    values: [
      { x: 0, y: 5 },
      { x: 1, y: 25 },
      { x: 2, y: 30 }
    ]
  }
];

Общие шкалы для всех серий

Шкалы строятся на объединённых данных:

const allValues = data.flatMap(d => d.values);

const xScale = d3.scaleLinear()
  .domain(d3.extent(allValues, d => d.x))
  .range([margin.left, width - margin.right]);

const yScale = d3.scaleLinear()
  .domain([0, d3.max(allValues, d => d.y)])
  .nice()
  .range([height - margin.bottom, margin.top]);

Отрисовка нескольких линий

Каждая серия превращается в отдельный path:

const line = d3.line()
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

svg.selectAll(".line")
  .data(data)
  .enter()
  .append("path")
  .attr("class", "line")
  .attr("fill", "none")
  .attr("stroke", (d, i) => d3.schemeCategory10[i])
  .attr("stroke-width", 2)
  .attr("d", d => line(d.values));

Цветовое кодирование серий

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

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

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

.attr("stroke", d => color(d.name))

Подписи и легенда

Легенда связывает цвет и название серии:

const legend = svg.selectAll(".legend")
  .data(data)
  .enter()
  .append("g")
  .attr("transform", (d, i) => `translate(0,${i * 20})`);

legend.append("rect")
  .attr("x", width - 20)
  .attr("width", 10)
  .attr("height", 10)
  .attr("fill", d => color(d.name));

legend.append("text")
  .attr("x", width - 25)
  .attr("y", 10)
  .attr("text-anchor", "end")
  .text(d => d.name);

Анимация линий

Плавное появление линии реализуется через stroke-dasharray:

const path = svg.append("path")
  .datum(data)
  .attr("fill", "none")
  .attr("stroke", "steelblue")
  .attr("stroke-width", 2)
  .attr("d", line);

const totalLength = path.node().getTotalLength();

path
  .attr("stroke-dasharray", `${totalLength} ${totalLength}`)
  .attr("stroke-dashoffset", totalLength)
  .transition()
  .duration(1500)
  .attr("stroke-dashoffset", 0);

Обновление данных (data join)

D3.js использует принцип связывания данных с DOM. Для динамических графиков:

svg.selectAll(".line")
  .data(data)
  .join("path")
  .attr("fill", "none")
  .attr("stroke", d => color(d.name))
  .attr("d", d => line(d.values));

Это обеспечивает корректное обновление без пересоздания всей сцены.

Обработка пропусков данных

Линии могут прерываться при null значениях:

const line = d3.line()
  .defined(d => d.y !== null)
  .x(d => xScale(d.x))
  .y(d => yScale(d.y));

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

Адаптивный график учитывает размеры контейнера:

function resize() {
  const width = container.clientWidth;

  xScale.range([margin.left, width - margin.right]);

  svg.selectAll("path").attr("d", line);
}

Производительность при больших данных

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

  • упрощение геометрии (sampling)
  • использование Canvas вместо SVG
  • минимизация повторных пересчётов scale
  • кэширование вычислений координат

Разделение логики построения

Структурирование кода улучшает поддерживаемость:

function createLineChart({ svg, data, xScale, yScale, color }) {
  const line = d3.line()
    .x(d => xScale(d.x))
    .y(d => yScale(d.y));

  svg.selectAll(".line")
    .data(data)
    .join("path")
    .attr("fill", "none")
    .attr("stroke", d => color(d.name))
    .attr("d", d => line(d.values));
}

Сравнение односерийного и многосерийного подхода

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

Главное отличие проявляется в масштабировании доменов: при нескольких сериях учитывается весь набор значений, а не отдельная последовательность.

Координатная согласованность

Все серии обязаны использовать одинаковые шкалы. Несоблюдение этого правила приводит к визуальному искажению сравнения данных. В D3.js это достигается едиными scaleLinear для всех path.