Вторичные оси и оси на нескольких уровнях

Визуализация данных часто требует одновременного отображения нескольких шкал измерения. Классический пример — графики, где одна величина отображается по левой оси, а вторая — по правой. В D3.js такие конструкции реализуются через комбинацию независимых шкал и повторное использование генераторов осей, связанных с разными наборами данных или разными преобразованиями одной и той же области значений.

Базовая концепция независимых шкал

Основа любой оси в D3.js — это масштаб (scale). Именно он определяет соответствие между значениями данных и пикселями на экране.

Для построения вторичной оси используется отдельный масштаб:

const xScale = d3.scaleLinear()
  .domain([0, 100])
  .range([0, width]);

const yLeftScale = d3.scaleLinear()
  .domain([0, 1000])
  .range([height, 0]);

const yRightScale = d3.scaleLinear()
  .domain([0, 1])
  .range([height, 0]);

Здесь создаются две независимые вертикальные шкалы, которые будут визуализированы как две оси.

Создание нескольких осей на одном графике

Каждая ось в D3 создаётся через соответствующий генератор:

const yAxisLeft = d3.axisLeft(yLeftScale);
const yAxisRight = d3.axisRight(yRightScale);
const xAxis = d3.axisBottom(xScale);

Далее оси добавляются в SVG как отдельные группы:

svg.append("g")
  .attr("transform", `translate(50, 0)`)
  .call(yAxisLeft);

svg.append("g")
  .attr("transform", `translate(${width + 50}, 0)`)
  .call(yAxisRight);

svg.append("g")
  .attr("transform", `translate(50, ${height})`)
  .call(xAxis);

Ключевой момент: каждая ось живёт в своей группе <g>, а позиционирование выполняется через transform.


Вторичные оси с общей шкалой и разными представлениями

Иногда вторичная ось не требует нового масштаба. Она может отображать те же значения, но в другой системе единиц.

Пример: шкала температуры в Цельсиях и Фаренгейтах.

const celsiusScale = d3.scaleLinear()
  .domain([0, 100])
  .range([height, 0]);

const fahrenheitScale = d3.scaleLinear()
  .domain([32, 212])
  .range([height, 0]);

Хотя математически это разные шкалы, их можно синхронизировать через преобразование домена:

const fahrenheitToCelsius = f => (f - 32) * 5 / 9;
const celsiusToFahrenheit = c => (c * 9 / 5) + 32;

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

const yScale = d3.scaleLinear()
  .domain([0, 100])
  .range([height, 0]);

const yAxisLeft = d3.axisLeft(yScale)
  .tickFormat(d => `${d}°C`);

const yAxisRight = d3.axisRight(yScale)
  .tickFormat(d => `${celsiusToFahrenheit(d)}°F`);

Преимущество подхода: синхронизация всегда сохраняется автоматически.


Смещение и выравнивание вторичной оси

При добавлении осей по обе стороны графика важно учитывать отступы. Обычно применяется фиксированный margin-паттерн:

const margin = { top: 20, right: 60, bottom: 40, left: 60 };

const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;

Все оси размещаются внутри сдвинутого контейнера:

const g = svg.append("g")
  .attr("transform", `translate(${margin.left}, ${margin.top})`);

Вторичная ось всегда смещается относительно правой границы:

g.append("g")
  .attr("transform", `translate(${innerWidth}, 0)`)
  .call(yAxisRight);

Форматирование вторичных осей

Одной из ключевых задач является различие визуального представления значений на разных осях.

Формат чисел

const yAxisRight = d3.axisRight(yRightScale)
  .ticks(5)
  .tickFormat(d => `${d.toFixed(2)} кг`);

Сложное форматирование

Можно использовать условную логику:

const formatTemperature = d => {
  if (d < 0) return `${d}°C (ниже нуля)`;
  if (d > 30) return `${d}°C (жарко)`;
  return `${d}°C`;
};

Оси с несколькими уровнями значений

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

Категориальная ось с группировкой

Используется scaleBand:

const xScale = d3.scaleBand()
  .domain(["Q1", "Q2", "Q3", "Q4"])
  .range([0, width])
  .padding(0.2);

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

const subCategories = {
  Q1: ["Jan", "Feb", "Mar"],
  Q2: ["Apr", "May", "Jun"]
};

Построение многоуровневой оси вручную

D3 не предоставляет встроенной иерархической оси, поэтому применяется ручная отрисовка:

const xAxisGroup = svg.append("g")
  .attr("transform", `translate(0, ${height})`);

Основной уровень:

xAxisGroup.call(d3.axisBottom(xScale));

Второй уровень (подписи):

Object.entries(subCategories).forEach(([key, values]) => {
  const startX = xScale(key);

  xAxisGroup.selectAll(null)
    .data(values)
    .enter()
    .append("text")
    .attr("x", startX)
    .attr("y", 20)
    .text(d => d);
});

Иерархические временные оси

Временные шкалы часто требуют нескольких уровней: год → месяц → день.

const timeScale = d3.scaleTime()
  .domain([new Date(2024, 0, 1), new Date(2024, 11, 31)])
  .range([0, width]);

Основная ось:

const xAxisYear = d3.axisBottom(timeScale)
  .ticks(d3.timeYear);

Вторичная ось:

const xAxisMonth = d3.axisBottom(timeScale)
  .ticks(d3.timeMonth.every(1));

Каждая ось размещается на разной вертикальной позиции:

svg.append("g")
  .attr("transform", `translate(0, ${height})`)
  .call(xAxisYear);

svg.append("g")
  .attr("transform", `translate(0, ${height + 20})`)
  .call(xAxisMonth);

Синхронизация нескольких осей

При наличии нескольких шкал важно поддерживать их согласованность.

Сценарий: масштабирование

Если изменяется domain одной шкалы:

yLeftScale.domain([0, newMax]);
yAxisLeft.scale(yLeftScale);

обязательно требуется перерисовка:

svg.select(".y-left-axis")
  .transition()
  .call(yAxisLeft);

Вторичные оси в комбинированных графиках

Частый кейс — линейный график + столбцы.

const yLinear = d3.scaleLinear()
  .domain([0, 100])
  .range([height, 0]);

const yBand = d3.scaleBand()
  .domain(["A", "B", "C"])
  .range([height, 0]);

Ось справа может отображать относительные значения:

const yRightAxis = d3.axisRight(yLinear)
  .tickFormat(d => `${d}%`);

Многоуровневые оси с группировкой и сеткой

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

const yGrid = d3.axisLeft(yLeftScale)
  .tickSize(-innerWidth)
  .tickFormat("");

Сетка отделяется от основной оси:

svg.append("g")
  .attr("class", "grid")
  .call(yGrid);

Ось остаётся самостоятельной:

svg.append("g")
  .attr("class", "axis")
  .call(yAxisLeft);

Кастомные многоуровневые подписи

Иногда требуется объединить несколько уровней в одной оси визуально:

const axisGroup = svg.append("g")
  .attr("transform", `translate(0, ${height})`);

Основные подписи:

axisGroup.call(d3.axisBottom(xScale));

Дополнительный слой:

axisGroup.selectAll(".sub-label")
  .data(data)
  .enter()
  .append("text")
  .attr("x", d => xScale(d.category))
  .attr("y", 25)
  .text(d => d.sub);

Практика композиции осей

Комбинация нескольких осей требует строгой структуры:

  • каждая ось в отдельной группе <g>
  • независимые scale для каждой логической оси
  • единый margin-контейнер
  • согласованное позиционирование через transform
const chart = svg.append("g")
  .attr("transform", `translate(${margin.left}, ${margin.top})`);

Далее все оси привязываются к этому контейнеру, что упрощает управление компоновкой и масштабированием.


Особенности работы с tickValues и ручным управлением уровнями

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

const axis = d3.axisBottom(xScale)
  .tickValues(["Jan", "Mar", "Jun", "Sep", "Dec"]);

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


Разделение визуальных слоёв осей

Для сложных графиков оси разделяются на слои:

  • базовый слой шкалы
  • слой вторичной информации
  • слой вспомогательных подписей
  • слой сетки

Каждый слой создаётся отдельным g, что позволяет независимо управлять стилями и анимацией.

svg.append("g").attr("class", "axis-left");
svg.append("g").attr("class", "axis-right");
svg.append("g").attr("class", "axis-bottom");
svg.append("g").attr("class", "grid");