Адаптивный размер шрифта при зуме

При использовании зума в D3.js возникает распространённая проблема: текст масштабируется вместе с графикой. В результате подписи становятся либо слишком крупными при увеличении, либо нечитаемо маленькими при уменьшении. Особенно заметно это в:

  • графах и сетевых диаграммах;
  • географических картах;
  • SVG-схемах;
  • интерактивных дашбордах;
  • временных шкалах и scatter plot.

Механизм d3.zoom() трансформирует весь контейнер через transform, включая координаты, размеры и шрифты. Если не контролировать размер текста отдельно, интерфейс быстро теряет читаемость.


Как работает zoom в D3.js

Поведение зума основано на трансформации SVG-группы:

const zoom = d3.zoom()
    .on("zoom", zoomed);

svg.call(zoom);

function zoomed(event) {
    container.attr("transform", event.transform);
}

Объект event.transform содержит:

  • x — смещение по X;
  • y — смещение по Y;
  • k — коэффициент масштаба.

При увеличении k растёт, и SVG начинает масштабировать всё содержимое группы.


Почему текст масштабируется

SVG рассматривает текст как обычный графический объект. Если группа увеличивается:

<g transform="scale(2)">

то одновременно увеличиваются:

  • координаты;
  • линии;
  • круги;
  • прямоугольники;
  • размер шрифта.

Например:

<text font-size="14">

при scale(2) визуально становится размером 28px.


Базовый подход: обратное масштабирование текста

Наиболее распространённый метод — компенсировать масштаб через деление размера шрифта на коэффициент k.

Пример

function zoomed(event) {

    container.attr("transform", event.transform);

    container.selectAll("text")
        .style("font-size", `${14 / event.transform.k}px`);
}

Теперь:

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

Полный пример адаптивного текста

const svg = d3.select("svg");

const container = svg.append("g");

container.append("circle")
    .attr("cx", 200)
    .attr("cy", 200)
    .attr("r", 50);

container.append("text")
    .attr("x", 200)
    .attr("y", 200)
    .text("Node A")
    .attr("text-anchor", "middle")
    .style("font-size", "16px");

const zoom = d3.zoom()
    .scaleExtent([0.5, 10])
    .on("zoom", zoomed);

svg.call(zoom);

function zoomed(event) {

    container.attr("transform", event.transform);

    container.selectAll("text")
        .style(
            "font-size",
            `${16 / event.transform.k}px`
        );
}

Что происходит математически

Если SVG увеличился в k раз:

s_{visual}=s_{font}k

где:

  • s_visual — фактический отображаемый размер;
  • s_font — установленный размер шрифта;
  • k — zoom scale.

Чтобы сохранить постоянный визуальный размер:

s_{font}=


Ограничение минимального и максимального размера

Полностью фиксированный размер текста подходит не всегда. При сильном отдалении надписи могут перекрывать экран.

Используется ограничение диапазона:

function zoomed(event) {

    container.attr("transform", event.transform);

    const scale = event.transform.k;

    const fontSize = Math.max(
        8,
        Math.min(24, 16 / scale)
    );

    container.selectAll("text")
        .style("font-size", `${fontSize}px`);
}

Поведение clamp

В примере используется:

Math.max(min, Math.min(max, value))

Это эквивалентно функции ограничения:

f(x)=(a,(b,x))

где:

  • a — минимальный размер;
  • b — максимальный размер.

Независимое масштабирование разных типов текста

В сложных визуализациях часто используются:

  • подписи узлов;
  • заголовки;
  • значения;
  • оси;
  • tooltip-подписи.

Каждый тип текста масштабируется отдельно.

Пример

function zoomed(event) {

    const k = event.transform.k;

    container.attr("transform", event.transform);

    container.selectAll(".node-label")
        .style("font-size", `${14 / k}px`);

    container.selectAll(".axis-label")
        .style("font-size", `${12 / Math.sqrt(k)}px`);

    container.selectAll(".title")
        .style("font-size", `${24 / k}px`);
}

Нелинейное масштабирование текста

Иногда требуется, чтобы текст уменьшался медленнее, чем остальные элементы.

Используется корень:

s=

Такой подход делает интерфейс более естественным:

  • текст всё же реагирует на zoom;
  • но не исчезает слишком быстро.

Масштабирование через transform

Иногда вместо изменения font-size используется обратный scale.

Пример

container.selectAll("text")
    .attr(
        "transform",
        `scale(${1 / event.transform.k})`
    );

Однако такой метод имеет недостатки:

  • смещаются координаты;
  • усложняется позиционирование;
  • возникают проблемы с text-anchor;
  • ухудшается читаемость.

Поэтому изменение font-size считается предпочтительным.


Использование vector-effect

Существует SVG-свойство:

vector-effect: non-scaling-stroke;

Оно предотвращает масштабирование линий, но:

  • не работает для текста;
  • не влияет на font-size.

Поэтому для шрифтов требуется отдельная логика.


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

При работе с картами D3 проблема особенно заметна:

  • города;
  • регионы;
  • подписи стран;
  • обозначения маршрутов.

Пример

function zoomed(event) {

    mapGroup.attr("transform", event.transform);

    mapGroup.selectAll(".country-label")
        .style(
            "font-size",
            `${10 / event.transform.k}px`
        );
}

Динамическое скрытие текста

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

Текст скрывается при малом масштабе:

function zoomed(event) {

    const k = event.transform.k;

    labels
        .style("display", k < 1.5 ? "none" : "block");
}

Постепенное появление текста

Более плавный вариант — изменение прозрачности.

function zoomed(event) {

    const k = event.transform.k;

    labels.style("opacity", Math.min(1, k - 0.5));
}

Комбинация opacity и font-size

На практике часто используется сразу несколько механизмов:

function zoomed(event) {

    const k = event.transform.k;

    container.attr("transform", event.transform);

    container.selectAll(".label")
        .style("font-size", `${14 / k}px`)
        .style("opacity", Math.min(1, k / 2));
}

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

Если SVG содержит тысячи текстовых элементов, постоянное изменение font-size может стать дорогой операцией.

Проблемы:

  • частые reflow;
  • перерасчёт layout;
  • нагрузка на браузер;
  • падение FPS.

Оптимизация через requestAnimationFrame

let scheduled = false;

function zoomed(event) {

    container.attr("transform", event.transform);

    if (!scheduled) {

        scheduled = true;

        requestAnimationFrame(() => {

            container.selectAll("text")
                .style(
                    "font-size",
                    `${14 / event.transform.k}px`
                );

            scheduled = false;
        });
    }
}

Кэширование выборок

Нельзя постоянно выполнять:

container.selectAll("text")

Лучше сохранить выборку заранее.

Плохо

function zoomed(event) {
    container.selectAll("text")
}

Хорошо

const labels = container.selectAll("text");

function zoomed(event) {

    labels.style(
        "font-size",
        `${14 / event.transform.k}px`
    );
}

Разделение слоёв

Эффективная архитектура SVG:

<svg>
    <g class="zoom-layer"></g>
    <g class="ui-layer"></g>
</svg>

Назначение слоёв

Слой Назначение
zoom-layer Масштабируемая графика
ui-layer Интерфейс и фиксированный текст

Фиксированный UI-слой

Иногда текст вообще не должен участвовать в zoom.

Пример

function zoomed(event) {

    zoomLayer.attr(
        "transform",
        event.transform
    );
}

Текст в ui-layer:

  • не масштабируется;
  • не двигается;
  • остаётся стабильным.

Такой подход используется в:

  • HUD-интерфейсах;
  • мини-картах;
  • overlay-подписях;
  • аналитических панелях.

Адаптивный текст в force-directed graph

В force simulation подписи постоянно перемещаются вместе с узлами.

Пример

simulation.on("tick", () => {

    nodes
        .attr("cx", d => d.x)
        .attr("cy", d => d.y);

    labels
        .attr("x", d => d.x)
        .attr("y", d => d.y);
});

При zoom:

function zoomed(event) {

    graph.attr("transform", event.transform);

    labels.style(
        "font-size",
        `${12 / event.transform.k}px`
    );
}

SVG против Canvas

В SVG изменение font-size удобно, но дорого.

Canvas работает иначе:

context.font = `${12 / scale}px Arial`;

Canvas:

  • быстрее на больших объёмах;
  • хуже подходит для DOM-взаимодействия;
  • не имеет встроенных text-элементов.

SVG:

  • проще для интерактивности;
  • удобнее для событий;
  • легче стилизуется CSS.

Использование em вместо px

Вместо пикселей можно использовать относительные единицы.

.style("font-size", `${1 / k}em`)

Но:

  • SVG не всегда одинаково интерпретирует em;
  • появляются каскадные эффекты;
  • сложнее контролировать результат.

Для D3-визуализаций чаще применяются px.


Адаптация толщины шрифта

При сильном уменьшении тонкий текст становится нечитаемым.

Используется динамический font-weight.

labels.style(
    "font-weight",
    event.transform.k > 3 ? 700 : 400
);

Поддержка Retina и HiDPI

На дисплеях высокой плотности возникают проблемы:

  • размытость;
  • дрожание текста;
  • артефакты subpixel positioning.

Часто помогает:

text {
    text-rendering: geometricPrecision;
}

или:

text {
    shape-rendering: crispEdges;
}

Проблема дробных размеров

При масштабе:

14 / 1.333333

получаются дробные пиксели.

Это может вызывать:

  • размытие;
  • мерцание;
  • нестабильный рендер.

Решение:

.style(
    "font-size",
    `${Math.round(14 / k)}px`
)

Интерполяция размеров при анимации

Во время плавного zoom браузер постоянно изменяет шрифт.

D3 использует интерполяцию transform:

svg.transition()
    .duration(750)
    .call(
        zoom.transform,
        d3.zoomIdentity.scale(4)
    );

Текст также анимируется:

labels.transition()
    .style("font-size", "6px");

Практический паттерн production-приложений

Наиболее стабильная архитектура:

  1. Отдельный zoom-layer.
  2. Отдельный слой текста.
  3. Ограничение min/max размера.
  4. Частичное скрытие мелких подписей.
  5. Кэширование DOM-выборок.
  6. requestAnimationFrame для оптимизации.
  7. Нелинейное масштабирование через sqrt(k).

Универсальная реализация

const BASE_FONT = 14;
const MIN_FONT = 8;
const MAX_FONT = 24;

const labels = container.selectAll(".label");

function zoomed(event) {

    const k = event.transform.k;

    container.attr("transform", event.transform);

    const size = Math.max(
        MIN_FONT,
        Math.min(
            MAX_FONT,
            BASE_FONT / Math.sqrt(k)
        )
    );

    labels
        .style("font-size", `${size}px`)
        .style(
            "opacity",
            Math.min(1, k / 2)
        );
}

Основные стратегии адаптивного текста

Подход Преимущества Недостатки
Обратный font-size Простота Частые DOM-обновления
scale(1/k) Быстро Сложное позиционирование
Отдельный UI-layer Максимальная стабильность Дополнительная архитектура
Скрытие текста Повышение читаемости Потеря информации
Нелинейное масштабирование Естественное поведение Более сложная настройка

Наиболее распространённые ошибки

Масштабирование всей SVG без контроля текста

svg.attr("transform", event.transform);

Это приводит к гигантским надписям.


Отсутствие ограничений размера

14 / k

При большом k текст становится микроскопическим.


Постоянные selectAll внутри zoom

function zoomed() {
    container.selectAll("text")
}

Это ухудшает производительность.


Использование transition внутри zoom-события

.on("zoom", () => {
    labels.transition()
})

Вызывает лаги и накопление анимаций.


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

В production-системах текст обычно делится на уровни:

Уровень Поведение
Critical labels Всегда видимы
Secondary labels Скрываются при уменьшении
Detailed labels Показываются только при приближении

Такой подход используется в:

  • GIS-системах;
  • BI-платформах;
  • редакторах графов;
  • CAD-интерфейсах;
  • интерактивных картах.