Адаптация осей и делений под маленькие экраны

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

  • подписи осей накладываются друг на друга;
  • деления становятся слишком частыми;
  • текст выходит за пределы SVG;
  • график теряет пропорции;
  • значения перестают различаться визуально.

Главная задача адаптации — сохранить информативность осей при ограниченном пространстве.


Базовая структура адаптивной диаграммы

Типичная структура 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);

Поворот подписей оси

Если сокращения недостаточно, подписи поворачиваются.

Поворот на 45 градусов

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();
}

Использование медиазапросов совместно с D3

CSS может участвовать в адаптации осей.

.tick text {
    font-size: 14px;
}

@media (max-width: 500px) {

    .tick text {
        font-size: 10px;
    }
}

Однако CSS не способен:

  • уменьшать количество делений;
  • менять tickValues;
  • управлять шкалами;
  • изменять форматирование данных.

Поэтому основная логика адаптации остаётся в Javascript.


Адаптация осей для touch-интерфейсов

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

Из-за этого:

  • элементы должны быть крупнее;
  • расстояние между делениями увеличивается;
  • мелкие подписи становятся бесполезными.

Практический минимум:

.tickSize(6)

и:

font-size: 12px

Адаптация категориальных шкал

Категориальные оси особенно плохо масштабируются.

Пример проблемной ситуации:

50 категорий

На мобильном экране это практически нечитаемо.

Решения:

  1. Показывать часть категорий.
  2. Использовать горизонтальную прокрутку.
  3. Группировать категории.
  4. Применять сокращения.
  5. Переключать тип визуализации.

Горизонтальная прокрутка

Иногда лучше сохранить информативность ценой скролла.

.chart-wrapper {
    overflow-x: auto;
}

SVG:

.attr("width", 1200)

Такой подход полезен для:

  • финансовых графиков;
  • таймлайнов;
  • больших таблиц;
  • heatmap-визуализаций.

Минималистичные мобильные оси

На маленьких экранах эффективнее работают упрощённые оси.

Часто используются:

  • только крайние значения;
  • минимум делений;
  • отсутствие сетки;
  • сокращённые подписи;
  • скрытая доменная линия.

Пример:

const xAxis = d3.axisBottom(xScale)
    .tickValues([
        data[0],
        data[data.length - 1]
    ]);

Адаптация оси Y

Проблемы оси 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 может вызывать лаги.

Особенно при:

  • resize окна;
  • анимациях;
  • потоковых данных.

Debounce при resize

let timeout;

window.addEventListener("resize", () => {

    clearTimeout(timeout);

    timeout = setTimeout(() => {
        renderChart(window.innerWidth);
    }, 100);
});

Переиспользование осей

Вместо полного удаления SVG:

axis.call(xAxis);

Это эффективнее, чем:

svg.remove();

Адаптация под Retina-дисплеи

Тонкие линии могут выглядеть размыто.

Решение:

shape-rendering: crispEdges;

или:

.style("shape-rendering", "crispEdges");

Типичные ошибки

Уменьшение только шрифта

Плохой подход:

font-size: 6px

Информация остаётся перегруженной.


Слишком много делений

Даже короткие подписи начинают сливаться.


Игнорирование отступов

После поворота текста подписи обрезаются.


Фиксированное количество ticks

.ticks(10)

На мобильных экранах это почти всегда ошибка.


Практические рекомендации

Для экранов меньше 500px

Обычно эффективно:

  • ticks(3–5)
  • шрифт 10–12px
  • сокращённые подписи
  • поворот -45
  • увеличенный margin.bottom

Для временных шкал

Лучше:

  • увеличивать временной интервал;
  • уменьшать детализацию;
  • использовать краткие форматы даты.

Для категориальных данных

Предпочтительно:

  • скрывать часть категорий;
  • сокращать подписи;
  • использовать scroll-container.

Архитектура адаптивных осей

Хорошая практика — вынесение логики адаптации в отдельные функции.

function getTickCount(width) {

    if (width < 400) return 3;
    if (width < 700) return 5;

    return 10;
}
function getFontSize(width) {

    return width < 500
        ? "10px"
        : "14px";
}

Такой подход:

  • упрощает поддержку;
  • делает код предсказуемым;
  • уменьшает дублирование;
  • облегчает масштабирование проекта.