При построении визуализаций в D3.js оси становятся одной из первых проблем адаптивного интерфейса. Диаграмма, отлично выглядящая на десктопе, на мобильном устройстве быстро теряет читаемость:
Главная задача адаптации — сохранить информативность осей при ограниченном пространстве.
Типичная структура SVG с учётом адаптивности:
const margin = {
top: 20,
right: 20,
bottom: 50,
left: 50
};
const width = container.clientWidth - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const svg = d3.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
.style("width", "100%")
.style("height", "auto");
const g = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
Ключевую роль играет использование viewBox вместо
фиксированных размеров. Это позволяет SVG масштабироваться под размеры
контейнера.
Наиболее частая проблема мобильных экранов — слишком длинные подписи по оси X.
Пример:
const data = [
"January",
"February",
"March",
"April",
"May",
"June"
];
На узком экране такие подписи начинают пересекаться.
ticks()Для количественных шкал число делений можно уменьшить:
const xAxis = d3.axisBottom(xScale)
.ticks(4);
На десктопе:
.ticks(10)
На мобильном устройстве:
.ticks(4)
Обычно количество делений выбирается динамически:
const tickCount = width < 500 ? 4 : 10;
const xAxis = d3.axisBottom(xScale)
.ticks(tickCount);
Временные оси особенно быстро перегружаются.
Без адаптации:
const xAxis = d3.axisBottom(timeScale)
.ticks(d3.timeDay.every(1));
Для мобильных устройств лучше увеличивать интервал:
const xAxis = d3.axisBottom(timeScale)
.ticks(
width < 500
? d3.timeWeek.every(1)
: d3.timeDay.every(1)
);
tickFormat()D3 позволяет изменять отображение значений:
const xAxis = d3.axisBottom(xScale)
.tickFormat(d => d.slice(0, 3));
Результат:
January -> Jan
February -> Feb
Для чисел:
.tickFormat(d3.format(".2s"))
Пример:
1200 -> 1.2k
2500000 -> 2.5M
Размер экрана может определять формат отображения:
const format = width < 500
? d => d.slice(0, 3)
: d => d;
const xAxis = d3.axisBottom(xScale)
.tickFormat(format);
Если сокращения недостаточно, подписи поворачиваются.
g.append("g")
.attr("transform", `translate(0, ${height})`)
.call(xAxis)
.selectAll("text")
.attr("transform", "rotate(-45)")
.style("text-anchor", "end");
Преимущества:
Недостатки:
Иногда используется поворот на 90 градусов:
.attr("transform", "rotate(-90)")
Такой подход подходит только при крайне ограниченной ширине.
D3 позволяет вручную контролировать отображаемые значения.
Пример:
const filtered = data.filter((d, i) => i % 2 === 0);
const xAxis = d3.axisBottom(xScale)
.tickValues(filtered);
На мобильных экранах часто показывается только каждое второе или третье значение.
const tickValues = width < 500
? data.filter((d, i) => i % 3 === 0)
: data;
const xAxis = d3.axisBottom(xScale)
.tickValues(tickValues);
Такой подход даёт значительно лучший результат, чем простое уменьшение размера шрифта.
Масштабирование текста — базовый инструмент responsive-дизайна.
g.selectAll(".tick text")
.style("font-size", width < 500 ? "10px" : "14px");
Слишком маленький текст ухудшает UX, поэтому обычно не рекомендуется
опускаться ниже 9px.
На мобильных устройствах оси требуют иных margin.
bottom: 40
bottom: 80
Особенно это важно при повороте подписей.
Пример:
const margin = {
top: 20,
right: 20,
bottom: width < 500 ? 80 : 40,
left: 50
};
Сетка и деления могут перегружать интерфейс.
g.select(".domain").remove();
const xAxis = d3.axisBottom(xScale)
.tickSize(4);
g.selectAll(".tick line")
.style("opacity", 0.3);
На маленьких экранах сетка должна быть менее агрессивной.
const gridTicks = width < 500 ? 4 : 10;
const grid = d3.axisLeft(yScale)
.ticks(gridTicks)
.tickSize(-width)
.tickFormat("");
ResizeObserverНастоящая адаптивность невозможна без отслеживания изменения размеров контейнера.
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
renderChart(entry.contentRect.width);
}
});
observer.observe(document.querySelector("#chart"));
function renderChart(containerWidth) {
d3.select("svg").remove();
const margin = {
top: 20,
right: 20,
bottom: containerWidth < 500 ? 80 : 40,
left: 50
};
const width = containerWidth - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const svg = d3.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${containerWidth} 400`)
.style("width", "100%");
const g = svg.append("g")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
const data = [
"January",
"February",
"March",
"April",
"May",
"June"
];
const xScale = d3.scaleBand()
.domain(data)
.range([0, width]);
const tickValues = containerWidth < 500
? data.filter((d, i) => i % 2 === 0)
: data;
const xAxis = d3.axisBottom(xScale)
.tickValues(tickValues)
.tickFormat(d =>
containerWidth < 500
? d.slice(0, 3)
: d
);
const axis = g.append("g")
.attr("transform", `translate(0, ${height})`)
.call(xAxis);
if (containerWidth < 500) {
axis.selectAll("text")
.attr("transform", "rotate(-45)")
.style("text-anchor", "end")
.style("font-size", "10px");
}
axis.select(".domain").remove();
}
CSS может участвовать в адаптации осей.
.tick text {
font-size: 14px;
}
@media (max-width: 500px) {
.tick text {
font-size: 10px;
}
}
Однако CSS не способен:
tickValues;Поэтому основная логика адаптации остаётся в Javascript.
На мобильных устройствах взаимодействие осуществляется пальцем, а не курсором мыши.
Из-за этого:
Практический минимум:
.tickSize(6)
и:
font-size: 12px
Категориальные оси особенно плохо масштабируются.
Пример проблемной ситуации:
50 категорий
На мобильном экране это практически нечитаемо.
Решения:
Иногда лучше сохранить информативность ценой скролла.
.chart-wrapper {
overflow-x: auto;
}
SVG:
.attr("width", 1200)
Такой подход полезен для:
На маленьких экранах эффективнее работают упрощённые оси.
Часто используются:
Пример:
const xAxis = d3.axisBottom(xScale)
.tickValues([
data[0],
data[data.length - 1]
]);
Проблемы оси Y отличаются:
.tickFormat(d3.format(".1s"))
.tickFormat(d => `$${d / 1000}k`)
.tickFormat(d3.format(".0%"))
Иногда полезно вручную ограничивать длину:
.tickFormat(d =>
d.length > 10
? d.slice(0, 10) + "..."
: d
)
Для временных графиков удобно изменять детализацию.
12:00
12:30
13:00
13:30
12
13
14
Пример:
.tickFormat(
width < 500
? d3.timeFormat("%H")
: d3.timeFormat("%H:%M")
)
Частое перестроение SVG может вызывать лаги.
Особенно при:
let timeout;
window.addEventListener("resize", () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
renderChart(window.innerWidth);
}, 100);
});
Вместо полного удаления SVG:
axis.call(xAxis);
Это эффективнее, чем:
svg.remove();
Тонкие линии могут выглядеть размыто.
Решение:
shape-rendering: crispEdges;
или:
.style("shape-rendering", "crispEdges");
Плохой подход:
font-size: 6px
Информация остаётся перегруженной.
Даже короткие подписи начинают сливаться.
После поворота текста подписи обрезаются.
.ticks(10)
На мобильных экранах это почти всегда ошибка.
Обычно эффективно:
ticks(3–5)10–12px-45margin.bottomЛучше:
Предпочтительно:
Хорошая практика — вынесение логики адаптации в отдельные функции.
function getTickCount(width) {
if (width < 400) return 3;
if (width < 700) return 5;
return 10;
}
function getFontSize(width) {
return width < 500
? "10px"
: "14px";
}
Такой подход: