Визуализация данных часто требует одновременного отображения нескольких шкал измерения. Классический пример — графики, где одна величина отображается по левой оси, а вторая — по правой. В 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>transformconst chart = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
Далее все оси привязываются к этому контейнеру, что упрощает управление компоновкой и масштабированием.
При многоуровневых осях часто требуется полный контроль над делениями:
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");